diff options
Diffstat (limited to 'src/main/java/com')
63 files changed, 13946 insertions, 684 deletions
diff --git a/src/main/java/com/gitblit/Constants.java b/src/main/java/com/gitblit/Constants.java index d425cdac..5b71eeb9 100644 --- a/src/main/java/com/gitblit/Constants.java +++ b/src/main/java/com/gitblit/Constants.java @@ -108,12 +108,18 @@ public class Constants { public static final String R_CHANGES = "refs/changes/";
- public static final String R_PULL= "refs/pull/";
+ public static final String R_PULL = "refs/pull/";
public static final String R_TAGS = "refs/tags/";
public static final String R_REMOTES = "refs/remotes/";
+ public static final String R_FOR = "refs/for/";
+
+ public static final String R_TICKET = "refs/heads/ticket/";
+
+ public static final String R_TICKETS_PATCHSETS = "refs/tickets/";
+
public static String getVersion() {
String v = Constants.class.getPackage().getImplementationVersion();
if (v == null) {
diff --git a/src/main/java/com/gitblit/GitBlit.java b/src/main/java/com/gitblit/GitBlit.java index da80a746..a1abfcd1 100644 --- a/src/main/java/com/gitblit/GitBlit.java +++ b/src/main/java/com/gitblit/GitBlit.java @@ -19,12 +19,14 @@ import java.text.MessageFormat; import java.util.ArrayList; import java.util.List; +import javax.inject.Singleton; import javax.servlet.http.HttpServletRequest; import com.gitblit.Constants.AccessPermission; import com.gitblit.manager.GitblitManager; import com.gitblit.manager.IAuthenticationManager; import com.gitblit.manager.IFederationManager; +import com.gitblit.manager.IGitblit; import com.gitblit.manager.INotificationManager; import com.gitblit.manager.IProjectManager; import com.gitblit.manager.IRepositoryManager; @@ -34,8 +36,17 @@ import com.gitblit.manager.ServicesManager; import com.gitblit.models.RepositoryModel; import com.gitblit.models.RepositoryUrl; import com.gitblit.models.UserModel; +import com.gitblit.tickets.BranchTicketService; +import com.gitblit.tickets.FileTicketService; +import com.gitblit.tickets.ITicketService; +import com.gitblit.tickets.NullTicketService; +import com.gitblit.tickets.RedisTicketService; import com.gitblit.utils.StringUtils; +import dagger.Module; +import dagger.ObjectGraph; +import dagger.Provides; + /** * GitBlit is the aggregate manager for the Gitblit webapp. It provides all * management functions and also manages some long-running services. @@ -45,8 +56,12 @@ import com.gitblit.utils.StringUtils; */ public class GitBlit extends GitblitManager { + private final ObjectGraph injector; + private final ServicesManager servicesManager; + private ITicketService ticketService; + public GitBlit( IRuntimeManager runtimeManager, INotificationManager notificationManager, @@ -64,6 +79,8 @@ public class GitBlit extends GitblitManager { projectManager, federationManager); + this.injector = ObjectGraph.create(getModules()); + this.servicesManager = new ServicesManager(this); } @@ -72,6 +89,7 @@ public class GitBlit extends GitblitManager { super.start(); logger.info("Starting services manager..."); servicesManager.start(); + configureTicketService(); return this; } @@ -79,9 +97,14 @@ public class GitBlit extends GitblitManager { public GitBlit stop() { super.stop(); servicesManager.stop(); + ticketService.stop(); return this; } + protected Object [] getModules() { + return new Object [] { new GitBlitModule()}; + } + /** * Returns a list of repository URLs and the user access permission. * @@ -131,4 +154,135 @@ public class GitBlit extends GitblitManager { } return list; } + + /** + * Detect renames and reindex as appropriate. + */ + @Override + public void updateRepositoryModel(String repositoryName, RepositoryModel repository, + boolean isCreate) throws GitBlitException { + RepositoryModel oldModel = null; + boolean isRename = !isCreate && !repositoryName.equalsIgnoreCase(repository.name); + if (isRename) { + oldModel = repositoryManager.getRepositoryModel(repositoryName); + } + + super.updateRepositoryModel(repositoryName, repository, isCreate); + + if (isRename && ticketService != null) { + ticketService.rename(oldModel, repository); + } + } + + /** + * Delete the repository and all associated tickets. + */ + @Override + public boolean deleteRepository(String repositoryName) { + RepositoryModel repository = repositoryManager.getRepositoryModel(repositoryName); + boolean success = repositoryManager.deleteRepository(repositoryName); + if (success && ticketService != null) { + return ticketService.deleteAll(repository); + } + return success; + } + + /** + * Returns the configured ticket service. + * + * @return a ticket service + */ + @Override + public ITicketService getTicketService() { + return ticketService; + } + + protected void configureTicketService() { + String clazz = settings.getString(Keys.tickets.service, NullTicketService.class.getName()); + if (StringUtils.isEmpty(clazz)) { + clazz = NullTicketService.class.getName(); + } + try { + Class<? extends ITicketService> serviceClass = (Class<? extends ITicketService>) Class.forName(clazz); + ticketService = injector.get(serviceClass).start(); + if (ticketService.isReady()) { + logger.info("{} is ready.", ticketService); + } else { + logger.warn("{} is disabled.", ticketService); + } + } catch (Exception e) { + logger.error("failed to create ticket service " + clazz, e); + ticketService = injector.get(NullTicketService.class).start(); + } + } + + /** + * A nested Dagger graph is used for constructor dependency injection of + * complex classes. + * + * @author James Moger + * + */ + @Module( + library = true, + injects = { + IStoredSettings.class, + + // core managers + IRuntimeManager.class, + INotificationManager.class, + IUserManager.class, + IAuthenticationManager.class, + IRepositoryManager.class, + IProjectManager.class, + IFederationManager.class, + + // the monolithic manager + IGitblit.class, + + // ticket services + NullTicketService.class, + FileTicketService.class, + BranchTicketService.class, + RedisTicketService.class + } + ) + class GitBlitModule { + + @Provides @Singleton IStoredSettings provideSettings() { + return settings; + } + + @Provides @Singleton IRuntimeManager provideRuntimeManager() { + return runtimeManager; + } + + @Provides @Singleton INotificationManager provideNotificationManager() { + return notificationManager; + } + + @Provides @Singleton IUserManager provideUserManager() { + return userManager; + } + + @Provides @Singleton IAuthenticationManager provideAuthenticationManager() { + return authenticationManager; + } + + @Provides @Singleton IRepositoryManager provideRepositoryManager() { + return repositoryManager; + } + + @Provides @Singleton IProjectManager provideProjectManager() { + return projectManager; + } + + @Provides @Singleton IFederationManager provideFederationManager() { + return federationManager; + } + + @Provides @Singleton IGitblit provideGitblit() { + return GitBlit.this; + } + } } diff --git a/src/main/java/com/gitblit/ReindexTickets.java b/src/main/java/com/gitblit/ReindexTickets.java new file mode 100644 index 00000000..af3ca0b2 --- /dev/null +++ b/src/main/java/com/gitblit/ReindexTickets.java @@ -0,0 +1,183 @@ +/* + * Copyright 2014 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit; + +import java.io.File; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.List; + +import com.beust.jcommander.JCommander; +import com.beust.jcommander.Parameter; +import com.beust.jcommander.ParameterException; +import com.beust.jcommander.Parameters; +import com.gitblit.manager.IRepositoryManager; +import com.gitblit.manager.IRuntimeManager; +import com.gitblit.manager.RepositoryManager; +import com.gitblit.manager.RuntimeManager; +import com.gitblit.tickets.BranchTicketService; +import com.gitblit.tickets.FileTicketService; +import com.gitblit.tickets.ITicketService; +import com.gitblit.tickets.RedisTicketService; +import com.gitblit.utils.StringUtils; + +/** + * A command-line tool to reindex all tickets in all repositories when the + * indexes needs to be rebuilt. + * + * @author James Moger + * + */ +public class ReindexTickets { + + public static void main(String... args) { + ReindexTickets reindex = new ReindexTickets(); + + // filter out the baseFolder parameter + List<String> filtered = new ArrayList<String>(); + String folder = "data"; + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + if (arg.equals("--baseFolder")) { + if (i + 1 == args.length) { + System.out.println("Invalid --baseFolder parameter!"); + System.exit(-1); + } else if (!".".equals(args[i + 1])) { + folder = args[i + 1]; + } + i = i + 1; + } else { + filtered.add(arg); + } + } + + Params.baseFolder = folder; + Params params = new Params(); + JCommander jc = new JCommander(params); + try { + jc.parse(filtered.toArray(new String[filtered.size()])); + if (params.help) { + reindex.usage(jc, null); + return; + } + } catch (ParameterException t) { + reindex.usage(jc, t); + return; + } + + // load the settings + FileSettings settings = params.FILESETTINGS; + if (!StringUtils.isEmpty(params.settingsfile)) { + if (new File(params.settingsfile).exists()) { + settings = new FileSettings(params.settingsfile); + } + } + + // reindex tickets + reindex.reindex(new File(Params.baseFolder), settings); + System.exit(0); + } + + /** + * Display the command line usage of ReindexTickets. + * + * @param jc + * @param t + */ + protected final void usage(JCommander jc, ParameterException t) { + System.out.println(Constants.BORDER); + System.out.println(Constants.getGitBlitVersion()); + System.out.println(Constants.BORDER); + System.out.println(); + if (t != null) { + System.out.println(t.getMessage()); + System.out.println(); + } + if (jc != null) { + jc.usage(); + System.out + .println("\nExample:\n java -gitblit.jar com.gitblit.ReindexTickets --baseFolder c:\\gitblit-data"); + } + System.exit(0); + } + + /** + * Reindex all tickets + * + * @param settings + */ + protected void reindex(File baseFolder, IStoredSettings settings) { + // disable some services + settings.overrideSetting(Keys.web.allowLuceneIndexing, false); + settings.overrideSetting(Keys.git.enableGarbageCollection, false); + settings.overrideSetting(Keys.git.enableMirroring, false); + settings.overrideSetting(Keys.web.activityCacheDays, 0); + + IRuntimeManager runtimeManager = new RuntimeManager(settings, baseFolder).start(); + IRepositoryManager repositoryManager = new RepositoryManager(runtimeManager, null).start(); + + String serviceName = settings.getString(Keys.tickets.service, BranchTicketService.class.getSimpleName()); + if (StringUtils.isEmpty(serviceName)) { + System.err.println(MessageFormat.format("Please define a ticket service in \"{0}\"", Keys.tickets.service)); + System.exit(1); + } + ITicketService ticketService = null; + try { + Class<?> serviceClass = Class.forName(serviceName); + if (RedisTicketService.class.isAssignableFrom(serviceClass)) { + // Redis ticket service + ticketService = new RedisTicketService(runtimeManager, null, null, repositoryManager).start(); + } else if (BranchTicketService.class.isAssignableFrom(serviceClass)) { + // Branch ticket service + ticketService = new BranchTicketService(runtimeManager, null, null, repositoryManager).start(); + } else if (FileTicketService.class.isAssignableFrom(serviceClass)) { + // File ticket service + ticketService = new FileTicketService(runtimeManager, null, null, repositoryManager).start(); + } else { + System.err.println("Unknown ticket service " + serviceName); + System.exit(1); + } + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } + + ticketService.reindex(); + ticketService.stop(); + repositoryManager.stop(); + runtimeManager.stop(); + } + + /** + * JCommander Parameters. + */ + @Parameters(separators = " ") + public static class Params { + + public static String baseFolder; + + @Parameter(names = { "-h", "--help" }, description = "Show this help") + public Boolean help = false; + + private final FileSettings FILESETTINGS = new FileSettings(new File(baseFolder, Constants.PROPERTIES_FILE).getAbsolutePath()); + + @Parameter(names = { "--repositoriesFolder" }, description = "Git Repositories Folder") + public String repositoriesFolder = FILESETTINGS.getString(Keys.git.repositoriesFolder, "git"); + + @Parameter(names = { "--settings" }, description = "Path to alternative settings") + public String settingsfile; + } +} diff --git a/src/main/java/com/gitblit/client/EditRepositoryDialog.java b/src/main/java/com/gitblit/client/EditRepositoryDialog.java index ce22d72f..c3690f37 100644 --- a/src/main/java/com/gitblit/client/EditRepositoryDialog.java +++ b/src/main/java/com/gitblit/client/EditRepositoryDialog.java @@ -88,6 +88,12 @@ public class EditRepositoryDialog extends JDialog { private JTextField descriptionField;
+ private JCheckBox acceptNewPatchsets;
+
+ private JCheckBox acceptNewTickets;
+
+ private JCheckBox requireApproval; + private JCheckBox useIncrementalPushTags;
private JCheckBox showRemoteBranches;
@@ -205,6 +211,12 @@ public class EditRepositoryDialog extends JDialog { ownersPalette = new JPalette<String>(true);
+ acceptNewTickets = new JCheckBox(Translation.get("gb.acceptsNewTicketsDescription"),
+ anRepository.acceptNewTickets);
+ acceptNewPatchsets = new JCheckBox(Translation.get("gb.acceptsNewPatchsetsDescription"),
+ anRepository.acceptNewPatchsets);
+ requireApproval = new JCheckBox(Translation.get("gb.requireApprovalDescription"),
+ anRepository.requireApproval);
useIncrementalPushTags = new JCheckBox(Translation.get("gb.useIncrementalPushTagsDescription"),
anRepository.useIncrementalPushTags);
showRemoteBranches = new JCheckBox(
@@ -298,6 +310,12 @@ public class EditRepositoryDialog extends JDialog { fieldsPanel.add(newFieldPanel(Translation.get("gb.gcPeriod"), gcPeriod));
fieldsPanel.add(newFieldPanel(Translation.get("gb.gcThreshold"), gcThreshold));
+ fieldsPanel.add(newFieldPanel(Translation.get("gb.acceptsNewTickets"),
+ acceptNewTickets));
+ fieldsPanel.add(newFieldPanel(Translation.get("gb.acceptsNewPatchsets"),
+ acceptNewPatchsets));
+ fieldsPanel.add(newFieldPanel(Translation.get("gb.requireApproval"),
+ requireApproval));
fieldsPanel
.add(newFieldPanel(Translation.get("gb.enableIncrementalPushTags"), useIncrementalPushTags));
fieldsPanel.add(newFieldPanel(Translation.get("gb.showRemoteBranches"),
@@ -552,6 +570,9 @@ public class EditRepositoryDialog extends JDialog { : headRefField.getSelectedItem().toString();
repository.gcPeriod = (Integer) gcPeriod.getSelectedItem();
repository.gcThreshold = gcThreshold.getText();
+ repository.acceptNewPatchsets = acceptNewPatchsets.isSelected();
+ repository.acceptNewTickets = acceptNewTickets.isSelected();
+ repository.requireApproval = requireApproval.isSelected(); repository.useIncrementalPushTags = useIncrementalPushTags.isSelected();
repository.showRemoteBranches = showRemoteBranches.isSelected();
repository.skipSizeCalculation = skipSizeCalculation.isSelected();
diff --git a/src/main/java/com/gitblit/git/GitblitReceivePack.java b/src/main/java/com/gitblit/git/GitblitReceivePack.java index 35f0d866..3a0eff22 100644 --- a/src/main/java/com/gitblit/git/GitblitReceivePack.java +++ b/src/main/java/com/gitblit/git/GitblitReceivePack.java @@ -50,6 +50,7 @@ import com.gitblit.client.Translation; import com.gitblit.manager.IGitblit;
import com.gitblit.models.RepositoryModel;
import com.gitblit.models.UserModel;
+import com.gitblit.tickets.BranchTicketService;
import com.gitblit.utils.ArrayUtils;
import com.gitblit.utils.ClientLogger;
import com.gitblit.utils.CommitCache;
@@ -236,6 +237,16 @@ public class GitblitReceivePack extends ReceivePack implements PreReceiveHook, P default:
break;
}
+ } else if (ref.equals(BranchTicketService.BRANCH)) {
+ // ensure pushing user is an administrator OR an owner
+ // i.e. prevent ticket tampering
+ boolean permitted = user.canAdmin() || repository.isOwner(user.username);
+ if (!permitted) {
+ sendRejection(cmd, "{0} is not permitted to push to {1}", user.username, ref);
+ }
+ } else if (ref.startsWith(Constants.R_FOR)) {
+ // prevent accidental push to refs/for
+ sendRejection(cmd, "{0} is not configured to receive patchsets", repository.name);
}
}
diff --git a/src/main/java/com/gitblit/git/GitblitReceivePackFactory.java b/src/main/java/com/gitblit/git/GitblitReceivePackFactory.java index b8b49bcd..7976fe56 100644 --- a/src/main/java/com/gitblit/git/GitblitReceivePackFactory.java +++ b/src/main/java/com/gitblit/git/GitblitReceivePackFactory.java @@ -100,10 +100,17 @@ public class GitblitReceivePackFactory<X> implements ReceivePackFactory<X> { if (StringUtils.isEmpty(url)) { url = gitblitUrl; } - + final RepositoryModel repository = gitblit.getRepositoryModel(repositoryName); - final GitblitReceivePack rp = new GitblitReceivePack(gitblit, db, repository, user); + // Determine which receive pack to use for pushes + final GitblitReceivePack rp; + if (gitblit.getTicketService().isAcceptingNewPatchsets(repository)) { + rp = new PatchsetReceivePack(gitblit, db, repository, user); + } else { + rp = new GitblitReceivePack(gitblit, db, repository, user); + } + rp.setGitblitUrl(url); rp.setRefLogIdent(new PersonIdent(user.username, user.username + "@" + origin)); rp.setTimeout(timeout); diff --git a/src/main/java/com/gitblit/git/PatchsetCommand.java b/src/main/java/com/gitblit/git/PatchsetCommand.java new file mode 100644 index 00000000..21d2ac45 --- /dev/null +++ b/src/main/java/com/gitblit/git/PatchsetCommand.java @@ -0,0 +1,324 @@ +/* + * Copyright 2013 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.git; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.transport.ReceiveCommand; + +import com.gitblit.Constants; +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Change; +import com.gitblit.models.TicketModel.Field; +import com.gitblit.models.TicketModel.Patchset; +import com.gitblit.models.TicketModel.PatchsetType; +import com.gitblit.models.TicketModel.Status; +import com.gitblit.utils.ArrayUtils; +import com.gitblit.utils.StringUtils; + +/** + * + * A subclass of ReceiveCommand which constructs a ticket change based on a + * patchset and data derived from the push ref. + * + * @author James Moger + * + */ +public class PatchsetCommand extends ReceiveCommand { + + public static final String TOPIC = "t="; + + public static final String RESPONSIBLE = "r="; + + public static final String WATCH = "cc="; + + public static final String MILESTONE = "m="; + + protected final Change change; + + protected boolean isNew; + + protected long ticketId; + + public static String getBasePatchsetBranch(long ticketNumber) { + StringBuilder sb = new StringBuilder(); + sb.append(Constants.R_TICKETS_PATCHSETS); + long m = ticketNumber % 100L; + if (m < 10) { + sb.append('0'); + } + sb.append(m); + sb.append('/'); + sb.append(ticketNumber); + sb.append('/'); + return sb.toString(); + } + + public static String getTicketBranch(long ticketNumber) { + return Constants.R_TICKET + ticketNumber; + } + + public static String getReviewBranch(long ticketNumber) { + return "ticket-" + ticketNumber; + } + + public static String getPatchsetBranch(long ticketId, long patchset) { + return getBasePatchsetBranch(ticketId) + patchset; + } + + public static long getTicketNumber(String ref) { + if (ref.startsWith(Constants.R_TICKETS_PATCHSETS)) { + // patchset revision + + // strip changes ref + String p = ref.substring(Constants.R_TICKETS_PATCHSETS.length()); + // strip shard id + p = p.substring(p.indexOf('/') + 1); + // strip revision + p = p.substring(0, p.indexOf('/')); + // parse ticket number + return Long.parseLong(p); + } else if (ref.startsWith(Constants.R_TICKET)) { + String p = ref.substring(Constants.R_TICKET.length()); + // parse ticket number + return Long.parseLong(p); + } + return 0L; + } + + public PatchsetCommand(String username, Patchset patchset) { + super(patchset.isFF() ? ObjectId.fromString(patchset.parent) : ObjectId.zeroId(), + ObjectId.fromString(patchset.tip), null); + this.change = new Change(username); + this.change.patchset = patchset; + } + + public PatchsetType getPatchsetType() { + return change.patchset.type; + } + + public boolean isNewTicket() { + return isNew; + } + + public long getTicketId() { + return ticketId; + } + + public Change getChange() { + return change; + } + + /** + * Creates a "new ticket" change for the proposal. + * + * @param commit + * @param mergeTo + * @param ticketId + * @parem pushRef + */ + public void newTicket(RevCommit commit, String mergeTo, long ticketId, String pushRef) { + this.ticketId = ticketId; + isNew = true; + change.setField(Field.title, getTitle(commit)); + change.setField(Field.body, getBody(commit)); + change.setField(Field.status, Status.New); + change.setField(Field.mergeTo, mergeTo); + change.setField(Field.type, TicketModel.Type.Proposal); + + Set<String> watchSet = new TreeSet<String>(); + watchSet.add(change.author); + + // identify parameters passed in the push ref + if (!StringUtils.isEmpty(pushRef)) { + List<String> watchers = getOptions(pushRef, WATCH); + if (!ArrayUtils.isEmpty(watchers)) { + for (String cc : watchers) { + watchSet.add(cc.toLowerCase()); + } + } + + String milestone = getSingleOption(pushRef, MILESTONE); + if (!StringUtils.isEmpty(milestone)) { + // user provided milestone + change.setField(Field.milestone, milestone); + } + + String responsible = getSingleOption(pushRef, RESPONSIBLE); + if (!StringUtils.isEmpty(responsible)) { + // user provided responsible + change.setField(Field.responsible, responsible); + watchSet.add(responsible); + } + + String topic = getSingleOption(pushRef, TOPIC); + if (!StringUtils.isEmpty(topic)) { + // user provided topic + change.setField(Field.topic, topic); + } + } + + // set the watchers + change.watch(watchSet.toArray(new String[watchSet.size()])); + } + + /** + * + * @param commit + * @param mergeTo + * @param ticket + * @param pushRef + */ + public void updateTicket(RevCommit commit, String mergeTo, TicketModel ticket, String pushRef) { + + this.ticketId = ticket.number; + + if (ticket.isClosed()) { + // re-opening a closed ticket + change.setField(Field.status, Status.Open); + } + + // ticket may or may not already have an integration branch + if (StringUtils.isEmpty(ticket.mergeTo) || !ticket.mergeTo.equals(mergeTo)) { + change.setField(Field.mergeTo, mergeTo); + } + + if (ticket.isProposal() && change.patchset.commits == 1 && change.patchset.type.isRewrite()) { + + // Gerrit-style title and description updates from the commit + // message + String title = getTitle(commit); + String body = getBody(commit); + + if (!ticket.title.equals(title)) { + // title changed + change.setField(Field.title, title); + } + + if (!ticket.body.equals(body)) { + // description changed + change.setField(Field.body, body); + } + } + + Set<String> watchSet = new TreeSet<String>(); + watchSet.add(change.author); + + // update the patchset command metadata + if (!StringUtils.isEmpty(pushRef)) { + List<String> watchers = getOptions(pushRef, WATCH); + if (!ArrayUtils.isEmpty(watchers)) { + for (String cc : watchers) { + watchSet.add(cc.toLowerCase()); + } + } + + String milestone = getSingleOption(pushRef, MILESTONE); + if (!StringUtils.isEmpty(milestone) && !milestone.equals(ticket.milestone)) { + // user specified a (different) milestone + change.setField(Field.milestone, milestone); + } + + String responsible = getSingleOption(pushRef, RESPONSIBLE); + if (!StringUtils.isEmpty(responsible) && !responsible.equals(ticket.responsible)) { + // user specified a (different) responsible + change.setField(Field.responsible, responsible); + watchSet.add(responsible); + } + + String topic = getSingleOption(pushRef, TOPIC); + if (!StringUtils.isEmpty(topic) && !topic.equals(ticket.topic)) { + // user specified a (different) topic + change.setField(Field.topic, topic); + } + } + + // update the watchers + watchSet.removeAll(ticket.getWatchers()); + if (!watchSet.isEmpty()) { + change.watch(watchSet.toArray(new String[watchSet.size()])); + } + } + + @Override + public String getRefName() { + return getPatchsetBranch(); + } + + public String getPatchsetBranch() { + return getBasePatchsetBranch(ticketId) + change.patchset.number; + } + + public String getTicketBranch() { + return getTicketBranch(ticketId); + } + + private String getTitle(RevCommit commit) { + String title = commit.getShortMessage(); + return title; + } + + /** + * Returns the body of the commit message + * + * @return + */ + private String getBody(RevCommit commit) { + String body = commit.getFullMessage().substring(commit.getShortMessage().length()).trim(); + return body; + } + + /** Extracts a ticket field from the ref name */ + private static List<String> getOptions(String refName, String token) { + if (refName.indexOf('%') > -1) { + List<String> list = new ArrayList<String>(); + String [] strings = refName.substring(refName.indexOf('%') + 1).split(","); + for (String str : strings) { + if (str.toLowerCase().startsWith(token)) { + String val = str.substring(token.length()); + list.add(val); + } + } + return list; + } + return null; + } + + /** Extracts a ticket field from the ref name */ + private static String getSingleOption(String refName, String token) { + List<String> list = getOptions(refName, token); + if (list != null && list.size() > 0) { + return list.get(0); + } + return null; + } + + /** Extracts a ticket field from the ref name */ + public static String getSingleOption(ReceiveCommand cmd, String token) { + return getSingleOption(cmd.getRefName(), token); + } + + /** Extracts a ticket field from the ref name */ + public static List<String> getOptions(ReceiveCommand cmd, String token) { + return getOptions(cmd.getRefName(), token); + } + +}
\ No newline at end of file diff --git a/src/main/java/com/gitblit/git/PatchsetReceivePack.java b/src/main/java/com/gitblit/git/PatchsetReceivePack.java new file mode 100644 index 00000000..ae429d2e --- /dev/null +++ b/src/main/java/com/gitblit/git/PatchsetReceivePack.java @@ -0,0 +1,1129 @@ +/*
+ * Copyright 2013 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.git;
+
+import static org.eclipse.jgit.transport.BasePackPushConnection.CAPABILITY_SIDE_BAND_64K;
+
+import java.io.IOException;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.transport.ReceiveCommand.Result;
+import org.eclipse.jgit.transport.ReceiveCommand.Type;
+import org.eclipse.jgit.transport.ReceivePack;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.gitblit.Constants;
+import com.gitblit.Keys;
+import com.gitblit.manager.IGitblit;
+import com.gitblit.models.RepositoryModel;
+import com.gitblit.models.TicketModel;
+import com.gitblit.models.TicketModel.Change;
+import com.gitblit.models.TicketModel.Field;
+import com.gitblit.models.TicketModel.Patchset;
+import com.gitblit.models.TicketModel.PatchsetType;
+import com.gitblit.models.TicketModel.Status;
+import com.gitblit.models.UserModel;
+import com.gitblit.tickets.ITicketService;
+import com.gitblit.tickets.TicketMilestone;
+import com.gitblit.tickets.TicketNotifier;
+import com.gitblit.utils.ArrayUtils;
+import com.gitblit.utils.DiffUtils;
+import com.gitblit.utils.DiffUtils.DiffStat;
+import com.gitblit.utils.JGitUtils;
+import com.gitblit.utils.JGitUtils.MergeResult;
+import com.gitblit.utils.JGitUtils.MergeStatus;
+import com.gitblit.utils.RefLogUtils;
+import com.gitblit.utils.StringUtils;
+
+
+/**
+ * PatchsetReceivePack processes receive commands and allows for creating, updating,
+ * and closing Gitblit tickets. It also executes Groovy pre- and post- receive
+ * hooks.
+ *
+ * The patchset mechanism defined in this class is based on the ReceiveCommits class
+ * from the Gerrit code review server.
+ *
+ * The general execution flow is:
+ * <ol>
+ * <li>onPreReceive()</li>
+ * <li>executeCommands()</li>
+ * <li>onPostReceive()</li>
+ * </ol>
+ *
+ * @author Android Open Source Project
+ * @author James Moger
+ *
+ */
+public class PatchsetReceivePack extends GitblitReceivePack {
+
+ protected static final List<String> MAGIC_REFS = Arrays.asList(Constants.R_FOR, Constants.R_TICKET);
+
+ protected static final Pattern NEW_PATCHSET =
+ Pattern.compile("^refs/tickets/(?:[0-9a-zA-Z][0-9a-zA-Z]/)?([1-9][0-9]*)(?:/new)?$");
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(PatchsetReceivePack.class);
+
+ protected final ITicketService ticketService;
+
+ protected final TicketNotifier ticketNotifier;
+
+ private boolean requireCleanMerge;
+
+ public PatchsetReceivePack(IGitblit gitblit, Repository db, RepositoryModel repository, UserModel user) {
+ super(gitblit, db, repository, user);
+ this.ticketService = gitblit.getTicketService();
+ this.ticketNotifier = ticketService.createNotifier();
+ }
+
+ /** Returns the patchset ref root from the ref */
+ private String getPatchsetRef(String refName) {
+ for (String patchRef : MAGIC_REFS) {
+ if (refName.startsWith(patchRef)) {
+ return patchRef;
+ }
+ }
+ return null;
+ }
+
+ /** Checks if the supplied ref name is a patchset ref */
+ private boolean isPatchsetRef(String refName) {
+ return !StringUtils.isEmpty(getPatchsetRef(refName));
+ }
+
+ /** Checks if the supplied ref name is a change ref */
+ private boolean isTicketRef(String refName) {
+ return refName.startsWith(Constants.R_TICKETS_PATCHSETS);
+ }
+
+ /** Extracts the integration branch from the ref name */
+ private String getIntegrationBranch(String refName) {
+ String patchsetRef = getPatchsetRef(refName);
+ String branch = refName.substring(patchsetRef.length());
+ if (branch.indexOf('%') > -1) {
+ branch = branch.substring(0, branch.indexOf('%'));
+ }
+
+ String defaultBranch = "master";
+ try {
+ defaultBranch = getRepository().getBranch();
+ } catch (Exception e) {
+ LOGGER.error("failed to determine default branch for " + repository.name, e);
+ }
+
+ long ticketId = 0L;
+ try {
+ ticketId = Long.parseLong(branch);
+ } catch (Exception e) {
+ // not a number
+ }
+ if (ticketId > 0 || branch.equalsIgnoreCase("default") || branch.equalsIgnoreCase("new")) {
+ return defaultBranch;
+ }
+ return branch;
+ }
+
+ /** Extracts the ticket id from the ref name */
+ private long getTicketId(String refName) {
+ if (refName.startsWith(Constants.R_FOR)) {
+ String ref = refName.substring(Constants.R_FOR.length());
+ if (ref.indexOf('%') > -1) {
+ ref = ref.substring(0, ref.indexOf('%'));
+ }
+ try {
+ return Long.parseLong(ref);
+ } catch (Exception e) {
+ // not a number
+ }
+ } else if (refName.startsWith(Constants.R_TICKET) ||
+ refName.startsWith(Constants.R_TICKETS_PATCHSETS)) {
+ return PatchsetCommand.getTicketNumber(refName);
+ }
+ return 0L;
+ }
+
+ /** Returns true if the ref namespace exists */
+ private boolean hasRefNamespace(String ref) {
+ Map<String, Ref> blockingFors;
+ try {
+ blockingFors = getRepository().getRefDatabase().getRefs(ref);
+ } catch (IOException err) {
+ sendError("Cannot scan refs in {0}", repository.name);
+ LOGGER.error("Error!", err);
+ return true;
+ }
+ if (!blockingFors.isEmpty()) {
+ sendError("{0} needs the following refs removed to receive patchsets: {1}",
+ repository.name, blockingFors.keySet());
+ return true;
+ }
+ return false;
+ }
+
+ /** Removes change ref receive commands */
+ private List<ReceiveCommand> excludeTicketCommands(Collection<ReceiveCommand> commands) {
+ List<ReceiveCommand> filtered = new ArrayList<ReceiveCommand>();
+ for (ReceiveCommand cmd : commands) {
+ if (!isTicketRef(cmd.getRefName())) {
+ // this is not a ticket ref update
+ filtered.add(cmd);
+ }
+ }
+ return filtered;
+ }
+
+ /** Removes patchset receive commands for pre- and post- hook integrations */
+ private List<ReceiveCommand> excludePatchsetCommands(Collection<ReceiveCommand> commands) {
+ List<ReceiveCommand> filtered = new ArrayList<ReceiveCommand>();
+ for (ReceiveCommand cmd : commands) {
+ if (!isPatchsetRef(cmd.getRefName())) {
+ // this is a non-patchset ref update
+ filtered.add(cmd);
+ }
+ }
+ return filtered;
+ }
+
+ /** Process receive commands EXCEPT for Patchset commands. */
+ @Override
+ public void onPreReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
+ Collection<ReceiveCommand> filtered = excludePatchsetCommands(commands);
+ super.onPreReceive(rp, filtered);
+ }
+
+ /** Process receive commands EXCEPT for Patchset commands. */
+ @Override
+ public void onPostReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
+ Collection<ReceiveCommand> filtered = excludePatchsetCommands(commands);
+ super.onPostReceive(rp, filtered);
+
+ // send all queued ticket notifications after processing all patchsets
+ ticketNotifier.sendAll();
+ }
+
+ @Override
+ protected void validateCommands() {
+ // workaround for JGit's awful scoping choices
+ //
+ // set the patchset refs to OK to bypass checks in the super implementation
+ for (final ReceiveCommand cmd : filterCommands(Result.NOT_ATTEMPTED)) {
+ if (isPatchsetRef(cmd.getRefName())) {
+ if (cmd.getType() == ReceiveCommand.Type.CREATE) {
+ cmd.setResult(Result.OK);
+ }
+ }
+ }
+
+ super.validateCommands();
+ }
+
+ /** Execute commands to update references. */
+ @Override
+ protected void executeCommands() {
+ // workaround for JGit's awful scoping choices
+ //
+ // reset the patchset refs to NOT_ATTEMPTED (see validateCommands)
+ for (ReceiveCommand cmd : filterCommands(Result.OK)) {
+ if (isPatchsetRef(cmd.getRefName())) {
+ cmd.setResult(Result.NOT_ATTEMPTED);
+ }
+ }
+
+ List<ReceiveCommand> toApply = filterCommands(Result.NOT_ATTEMPTED);
+ if (toApply.isEmpty()) {
+ return;
+ }
+
+ ProgressMonitor updating = NullProgressMonitor.INSTANCE;
+ boolean sideBand = isCapabilityEnabled(CAPABILITY_SIDE_BAND_64K);
+ if (sideBand) {
+ SideBandProgressMonitor pm = new SideBandProgressMonitor(msgOut);
+ pm.setDelayStart(250, TimeUnit.MILLISECONDS);
+ updating = pm;
+ }
+
+ BatchRefUpdate batch = getRepository().getRefDatabase().newBatchUpdate();
+ batch.setAllowNonFastForwards(isAllowNonFastForwards());
+ batch.setRefLogIdent(getRefLogIdent());
+ batch.setRefLogMessage("push", true);
+
+ ReceiveCommand patchsetRefCmd = null;
+ PatchsetCommand patchsetCmd = null;
+ for (ReceiveCommand cmd : toApply) {
+ if (Result.NOT_ATTEMPTED != cmd.getResult()) {
+ // Already rejected by the core receive process.
+ continue;
+ }
+
+ if (isPatchsetRef(cmd.getRefName())) {
+ if (ticketService == null) {
+ sendRejection(cmd, "Sorry, the ticket service is unavailable and can not accept patchsets at this time.");
+ continue;
+ }
+
+ if (!ticketService.isReady()) {
+ sendRejection(cmd, "Sorry, the ticket service can not accept patchsets at this time.");
+ continue;
+ }
+
+ if (UserModel.ANONYMOUS.equals(user)) {
+ // server allows anonymous pushes, but anonymous patchset
+ // contributions are prohibited by design
+ sendRejection(cmd, "Sorry, anonymous patchset contributions are prohibited.");
+ continue;
+ }
+
+ final Matcher m = NEW_PATCHSET.matcher(cmd.getRefName());
+ if (m.matches()) {
+ // prohibit pushing directly to a patchset ref
+ long id = getTicketId(cmd.getRefName());
+ sendError("You may not directly push directly to a patchset ref!");
+ sendError("Instead, please push to one the following:");
+ sendError(" - {0}{1,number,0}", Constants.R_FOR, id);
+ sendError(" - {0}{1,number,0}", Constants.R_TICKET, id);
+ sendRejection(cmd, "protected ref");
+ continue;
+ }
+
+ if (hasRefNamespace(Constants.R_FOR)) {
+ // the refs/for/ namespace exists and it must not
+ LOGGER.error("{} already has refs in the {} namespace",
+ repository.name, Constants.R_FOR);
+ sendRejection(cmd, "Sorry, a repository administrator will have to remove the {} namespace", Constants.R_FOR);
+ continue;
+ }
+
+ if (patchsetRefCmd != null) {
+ sendRejection(cmd, "You may only push one patchset at a time.");
+ continue;
+ }
+
+ // responsible verification
+ String responsible = PatchsetCommand.getSingleOption(cmd, PatchsetCommand.RESPONSIBLE);
+ if (!StringUtils.isEmpty(responsible)) {
+ UserModel assignee = gitblit.getUserModel(responsible);
+ if (assignee == null) {
+ // no account by this name
+ sendRejection(cmd, "{0} can not be assigned any tickets because there is no user account by that name", responsible);
+ continue;
+ } else if (!assignee.canPush(repository)) {
+ // account does not have RW permissions
+ sendRejection(cmd, "{0} ({1}) can not be assigned any tickets because the user does not have RW permissions for {2}",
+ assignee.getDisplayName(), assignee.username, repository.name);
+ continue;
+ }
+ }
+
+ // milestone verification
+ String milestone = PatchsetCommand.getSingleOption(cmd, PatchsetCommand.MILESTONE);
+ if (!StringUtils.isEmpty(milestone)) {
+ TicketMilestone milestoneModel = ticketService.getMilestone(repository, milestone);
+ if (milestoneModel == null) {
+ // milestone does not exist
+ sendRejection(cmd, "Sorry, \"{0}\" is not a valid milestone!", milestone);
+ continue;
+ }
+ }
+
+ // watcher verification
+ List<String> watchers = PatchsetCommand.getOptions(cmd, PatchsetCommand.WATCH);
+ if (!ArrayUtils.isEmpty(watchers)) {
+ for (String watcher : watchers) {
+ UserModel user = gitblit.getUserModel(watcher);
+ if (user == null) {
+ // watcher does not exist
+ sendRejection(cmd, "Sorry, \"{0}\" is not a valid username for the watch list!", watcher);
+ continue;
+ }
+ }
+ }
+
+ patchsetRefCmd = cmd;
+ patchsetCmd = preparePatchset(cmd);
+ if (patchsetCmd != null) {
+ batch.addCommand(patchsetCmd);
+ }
+ continue;
+ }
+
+ batch.addCommand(cmd);
+ }
+
+ if (!batch.getCommands().isEmpty()) {
+ try {
+ batch.execute(getRevWalk(), updating);
+ } catch (IOException err) {
+ for (ReceiveCommand cmd : toApply) {
+ if (cmd.getResult() == Result.NOT_ATTEMPTED) {
+ sendRejection(cmd, "lock error: {0}", err.getMessage());
+ }
+ }
+ }
+ }
+
+ //
+ // set the results into the patchset ref receive command
+ //
+ if (patchsetRefCmd != null && patchsetCmd != null) {
+ if (!patchsetCmd.getResult().equals(Result.OK)) {
+ // patchset command failed!
+ LOGGER.error(patchsetCmd.getType() + " " + patchsetCmd.getRefName()
+ + " " + patchsetCmd.getResult());
+ patchsetRefCmd.setResult(patchsetCmd.getResult(), patchsetCmd.getMessage());
+ } else {
+ // all patchset commands were applied
+ patchsetRefCmd.setResult(Result.OK);
+
+ // update the ticket branch ref
+ RefUpdate ru = updateRef(patchsetCmd.getTicketBranch(), patchsetCmd.getNewId());
+ updateReflog(ru);
+
+ TicketModel ticket = processPatchset(patchsetCmd);
+ if (ticket != null) {
+ ticketNotifier.queueMailing(ticket);
+ }
+ }
+ }
+
+ //
+ // if there are standard ref update receive commands that were
+ // successfully processed, process referenced tickets, if any
+ //
+ List<ReceiveCommand> allUpdates = ReceiveCommand.filter(batch.getCommands(), Result.OK);
+ List<ReceiveCommand> refUpdates = excludePatchsetCommands(allUpdates);
+ List<ReceiveCommand> stdUpdates = excludeTicketCommands(refUpdates);
+ if (!stdUpdates.isEmpty()) {
+ int ticketsProcessed = 0;
+ for (ReceiveCommand cmd : stdUpdates) {
+ switch (cmd.getType()) {
+ case CREATE:
+ case UPDATE:
+ case UPDATE_NONFASTFORWARD:
+ Collection<TicketModel> tickets = processMergedTickets(cmd);
+ ticketsProcessed += tickets.size();
+ for (TicketModel ticket : tickets) {
+ ticketNotifier.queueMailing(ticket);
+ }
+ break;
+ default:
+ break;
+ }
+ }
+
+ if (ticketsProcessed == 1) {
+ sendInfo("1 ticket updated");
+ } else if (ticketsProcessed > 1) {
+ sendInfo("{0} tickets updated", ticketsProcessed);
+ }
+ }
+
+ // reset the ticket caches for the repository
+ ticketService.resetCaches(repository);
+ }
+
+ /**
+ * Prepares a patchset command.
+ *
+ * @param cmd
+ * @return the patchset command
+ */
+ private PatchsetCommand preparePatchset(ReceiveCommand cmd) {
+ String branch = getIntegrationBranch(cmd.getRefName());
+ long number = getTicketId(cmd.getRefName());
+
+ TicketModel ticket = null;
+ if (number > 0 && ticketService.hasTicket(repository, number)) {
+ ticket = ticketService.getTicket(repository, number);
+ }
+
+ if (ticket == null) {
+ if (number > 0) {
+ // requested ticket does not exist
+ sendError("Sorry, {0} does not have ticket {1,number,0}!", repository.name, number);
+ sendRejection(cmd, "Invalid ticket number");
+ return null;
+ }
+ } else {
+ if (ticket.isMerged()) {
+ // ticket already merged & resolved
+ Change mergeChange = null;
+ for (Change change : ticket.changes) {
+ if (change.isMerge()) {
+ mergeChange = change;
+ break;
+ }
+ }
+ sendError("Sorry, {0} already merged {1} from ticket {2,number,0} to {3}!",
+ mergeChange.author, mergeChange.patchset, number, ticket.mergeTo);
+ sendRejection(cmd, "Ticket {0,number,0} already resolved", number);
+ return null;
+ } else if (!StringUtils.isEmpty(ticket.mergeTo)) {
+ // ticket specifies integration branch
+ branch = ticket.mergeTo;
+ }
+ }
+
+ final int shortCommitIdLen = settings.getInteger(Keys.web.shortCommitIdLength, 6);
+ final String shortTipId = cmd.getNewId().getName().substring(0, shortCommitIdLen);
+ final RevCommit tipCommit = JGitUtils.getCommit(getRepository(), cmd.getNewId().getName());
+ final String forBranch = branch;
+ RevCommit mergeBase = null;
+ Ref forBranchRef = getAdvertisedRefs().get(Constants.R_HEADS + forBranch);
+ if (forBranchRef == null || forBranchRef.getObjectId() == null) {
+ // unknown integration branch
+ sendError("Sorry, there is no integration branch named ''{0}''.", forBranch);
+ sendRejection(cmd, "Invalid integration branch specified");
+ return null;
+ } else {
+ // determine the merge base for the patchset on the integration branch
+ String base = JGitUtils.getMergeBase(getRepository(), forBranchRef.getObjectId(), tipCommit.getId());
+ if (StringUtils.isEmpty(base)) {
+ sendError("");
+ sendError("There is no common ancestry between {0} and {1}.", forBranch, shortTipId);
+ sendError("Please reconsider your proposed integration branch, {0}.", forBranch);
+ sendError("");
+ sendRejection(cmd, "no merge base for patchset and {0}", forBranch);
+ return null;
+ }
+ mergeBase = JGitUtils.getCommit(getRepository(), base);
+ }
+
+ // ensure that the patchset can be cleanly merged right now
+ MergeStatus status = JGitUtils.canMerge(getRepository(), tipCommit.getName(), forBranch);
+ switch (status) {
+ case ALREADY_MERGED:
+ sendError("");
+ sendError("You have already merged this patchset.", forBranch);
+ sendError("");
+ sendRejection(cmd, "everything up-to-date");
+ return null;
+ case MERGEABLE:
+ break;
+ default:
+ if (ticket == null || requireCleanMerge) {
+ sendError("");
+ sendError("Your patchset can not be cleanly merged into {0}.", forBranch);
+ sendError("Please rebase your patchset and push again.");
+ sendError("NOTE:", number);
+ sendError("You should push your rebase to refs/for/{0,number,0}", number);
+ sendError("");
+ sendError(" git push origin HEAD:refs/for/{0,number,0}", number);
+ sendError("");
+ sendRejection(cmd, "patchset not mergeable");
+ return null;
+ }
+ }
+
+ // check to see if this commit is already linked to a ticket
+ long id = identifyTicket(tipCommit, false);
+ if (id > 0) {
+ sendError("{0} has already been pushed to ticket {1,number,0}.", shortTipId, id);
+ sendRejection(cmd, "everything up-to-date");
+ return null;
+ }
+
+ PatchsetCommand psCmd;
+ if (ticket == null) {
+ /*
+ * NEW TICKET
+ */
+ Patchset patchset = newPatchset(null, mergeBase.getName(), tipCommit.getName());
+
+ int minLength = 10;
+ int maxLength = 100;
+ String minTitle = MessageFormat.format(" minimum length of a title is {0} characters.", minLength);
+ String maxTitle = MessageFormat.format(" maximum length of a title is {0} characters.", maxLength);
+
+ if (patchset.commits > 1) {
+ sendError("");
+ sendError("To create a proposal ticket, please squash your commits and");
+ sendError("provide a meaningful commit message with a short title &");
+ sendError("an optional description/body.");
+ sendError("");
+ sendError(minTitle);
+ sendError(maxTitle);
+ sendError("");
+ sendRejection(cmd, "please squash to one commit");
+ return null;
+ }
+
+ // require a reasonable title/subject
+ String title = tipCommit.getFullMessage().trim().split("\n")[0];
+ if (title.length() < minLength) {
+ // reject, title too short
+ sendError("");
+ sendError("Please supply a longer title in your commit message!");
+ sendError("");
+ sendError(minTitle);
+ sendError(maxTitle);
+ sendError("");
+ sendRejection(cmd, "ticket title is too short [{0}/{1}]", title.length(), maxLength);
+ return null;
+ }
+ if (title.length() > maxLength) {
+ // reject, title too long
+ sendError("");
+ sendError("Please supply a more concise title in your commit message!");
+ sendError("");
+ sendError(minTitle);
+ sendError(maxTitle);
+ sendError("");
+ sendRejection(cmd, "ticket title is too long [{0}/{1}]", title.length(), maxLength);
+ return null;
+ }
+
+ // assign new id
+ long ticketId = ticketService.assignNewId(repository);
+
+ // create the patchset command
+ psCmd = new PatchsetCommand(user.username, patchset);
+ psCmd.newTicket(tipCommit, forBranch, ticketId, cmd.getRefName());
+ } else {
+ /*
+ * EXISTING TICKET
+ */
+ Patchset patchset = newPatchset(ticket, mergeBase.getName(), tipCommit.getName());
+ psCmd = new PatchsetCommand(user.username, patchset);
+ psCmd.updateTicket(tipCommit, forBranch, ticket, cmd.getRefName());
+ }
+
+ // confirm user can push the patchset
+ boolean pushPermitted = ticket == null
+ || !ticket.hasPatchsets()
+ || ticket.isAuthor(user.username)
+ || ticket.isPatchsetAuthor(user.username)
+ || ticket.isResponsible(user.username)
+ || user.canPush(repository);
+
+ switch (psCmd.getPatchsetType()) {
+ case Proposal:
+ // proposals (first patchset) are always acceptable
+ break;
+ case FastForward:
+ // patchset updates must be permitted
+ if (!pushPermitted) {
+ // reject
+ sendError("");
+ sendError("To push a patchset to this ticket one of the following must be true:");
+ sendError(" 1. you created the ticket");
+ sendError(" 2. you created the first patchset");
+ sendError(" 3. you are specified as responsible for the ticket");
+ sendError(" 4. you are listed as a reviewer for the ticket");
+ sendError(" 5. you have push (RW) permission to {0}", repository.name);
+ sendError("");
+ sendRejection(cmd, "not permitted to push to ticket {0,number,0}", ticket.number);
+ return null;
+ }
+ break;
+ default:
+ // non-fast-forward push
+ if (!pushPermitted) {
+ // reject
+ sendRejection(cmd, "non-fast-forward ({0})", psCmd.getPatchsetType());
+ return null;
+ }
+ break;
+ }
+ return psCmd;
+ }
+
+ /**
+ * Creates or updates an ticket with the specified patchset.
+ *
+ * @param cmd
+ * @return a ticket if the creation or update was successful
+ */
+ private TicketModel processPatchset(PatchsetCommand cmd) {
+ Change change = cmd.getChange();
+
+ if (cmd.isNewTicket()) {
+ // create the ticket object
+ TicketModel ticket = ticketService.createTicket(repository, cmd.getTicketId(), change);
+ if (ticket != null) {
+ sendInfo("");
+ sendHeader("#{0,number,0}: {1}", ticket.number, StringUtils.trimString(ticket.title, Constants.LEN_SHORTLOG));
+ sendInfo("created proposal ticket from patchset");
+ sendInfo(ticketService.getTicketUrl(ticket));
+ sendInfo("");
+
+ // log the new patch ref
+ RefLogUtils.updateRefLog(user, getRepository(),
+ Arrays.asList(new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), cmd.getRefName())));
+
+ return ticket;
+ } else {
+ sendError("FAILED to create ticket");
+ }
+ } else {
+ // update an existing ticket
+ TicketModel ticket = ticketService.updateTicket(repository, cmd.getTicketId(), change);
+ if (ticket != null) {
+ sendInfo("");
+ sendHeader("#{0,number,0}: {1}", ticket.number, StringUtils.trimString(ticket.title, Constants.LEN_SHORTLOG));
+ if (change.patchset.rev == 1) {
+ // new patchset
+ sendInfo("uploaded patchset {0} ({1})", change.patchset.number, change.patchset.type.toString());
+ } else {
+ // updated patchset
+ sendInfo("added {0} {1} to patchset {2}",
+ change.patchset.added,
+ change.patchset.added == 1 ? "commit" : "commits",
+ change.patchset.number);
+ }
+ sendInfo(ticketService.getTicketUrl(ticket));
+ sendInfo("");
+
+ // log the new patchset ref
+ RefLogUtils.updateRefLog(user, getRepository(),
+ Arrays.asList(new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), cmd.getRefName())));
+
+ // return the updated ticket
+ return ticket;
+ } else {
+ sendError("FAILED to upload {0} for ticket {1,number,0}", change.patchset, cmd.getTicketId());
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Automatically closes open tickets that have been merged to their integration
+ * branch by a client.
+ *
+ * @param cmd
+ */
+ private Collection<TicketModel> processMergedTickets(ReceiveCommand cmd) {
+ Map<Long, TicketModel> mergedTickets = new LinkedHashMap<Long, TicketModel>();
+ final RevWalk rw = getRevWalk();
+ try {
+ rw.reset();
+ rw.markStart(rw.parseCommit(cmd.getNewId()));
+ if (!ObjectId.zeroId().equals(cmd.getOldId())) {
+ rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
+ }
+
+ RevCommit c;
+ while ((c = rw.next()) != null) {
+ rw.parseBody(c);
+ long ticketNumber = identifyTicket(c, true);
+ if (ticketNumber == 0L || mergedTickets.containsKey(ticketNumber)) {
+ continue;
+ }
+
+ TicketModel ticket = ticketService.getTicket(repository, ticketNumber);
+ String integrationBranch;
+ if (StringUtils.isEmpty(ticket.mergeTo)) {
+ // unspecified integration branch
+ integrationBranch = null;
+ } else {
+ // specified integration branch
+ integrationBranch = Constants.R_HEADS + ticket.mergeTo;
+ }
+
+ // ticket must be open and, if specified, the ref must match the integration branch
+ if (ticket.isClosed() || (integrationBranch != null && !integrationBranch.equals(cmd.getRefName()))) {
+ continue;
+ }
+
+ String baseRef = PatchsetCommand.getBasePatchsetBranch(ticket.number);
+ boolean knownPatchset = false;
+ Set<Ref> refs = getRepository().getAllRefsByPeeledObjectId().get(c.getId());
+ if (refs != null) {
+ for (Ref ref : refs) {
+ if (ref.getName().startsWith(baseRef)) {
+ knownPatchset = true;
+ break;
+ }
+ }
+ }
+
+ String mergeSha = c.getName();
+ String mergeTo = Repository.shortenRefName(cmd.getRefName());
+ Change change;
+ Patchset patchset;
+ if (knownPatchset) {
+ // identify merged patchset by the patchset tip
+ patchset = null;
+ for (Patchset ps : ticket.getPatchsets()) {
+ if (ps.tip.equals(mergeSha)) {
+ patchset = ps;
+ break;
+ }
+ }
+
+ if (patchset == null) {
+ // should not happen - unless ticket has been hacked
+ sendError("Failed to find the patchset for {0} in ticket {1,number,0}?!",
+ mergeSha, ticket.number);
+ continue;
+ }
+
+ // create a new change
+ change = new Change(user.username);
+ } else {
+ // new patchset pushed by user
+ String base = cmd.getOldId().getName();
+ patchset = newPatchset(ticket, base, mergeSha);
+ PatchsetCommand psCmd = new PatchsetCommand(user.username, patchset);
+ psCmd.updateTicket(c, mergeTo, ticket, null);
+
+ // create a ticket patchset ref
+ updateRef(psCmd.getPatchsetBranch(), c.getId());
+ RefUpdate ru = updateRef(psCmd.getTicketBranch(), c.getId());
+ updateReflog(ru);
+
+ // create a change from the patchset command
+ change = psCmd.getChange();
+ }
+
+ // set the common change data about the merge
+ change.setField(Field.status, Status.Merged);
+ change.setField(Field.mergeSha, mergeSha);
+ change.setField(Field.mergeTo, mergeTo);
+
+ if (StringUtils.isEmpty(ticket.responsible)) {
+ // unassigned tickets are assigned to the closer
+ change.setField(Field.responsible, user.username);
+ }
+
+ ticket = ticketService.updateTicket(repository, ticket.number, change);
+ if (ticket != null) {
+ sendInfo("");
+ sendHeader("#{0,number,0}: {1}", ticket.number, StringUtils.trimString(ticket.title, Constants.LEN_SHORTLOG));
+ sendInfo("closed by push of {0} to {1}", patchset, mergeTo);
+ sendInfo(ticketService.getTicketUrl(ticket));
+ sendInfo("");
+ mergedTickets.put(ticket.number, ticket);
+ } else {
+ String shortid = mergeSha.substring(0, settings.getInteger(Keys.web.shortCommitIdLength, 6));
+ sendError("FAILED to close ticket {0,number,0} by push of {1}", ticketNumber, shortid);
+ }
+ }
+ } catch (IOException e) {
+ LOGGER.error("Can't scan for changes to close", e);
+ } finally {
+ rw.reset();
+ }
+
+ return mergedTickets.values();
+ }
+
+ /**
+ * Try to identify a ticket id from the commit.
+ *
+ * @param commit
+ * @param parseMessage
+ * @return a ticket id or 0
+ */
+ private long identifyTicket(RevCommit commit, boolean parseMessage) {
+ // try lookup by change ref
+ Map<AnyObjectId, Set<Ref>> map = getRepository().getAllRefsByPeeledObjectId();
+ Set<Ref> refs = map.get(commit.getId());
+ if (!ArrayUtils.isEmpty(refs)) {
+ for (Ref ref : refs) {
+ long number = PatchsetCommand.getTicketNumber(ref.getName());
+ if (number > 0) {
+ return number;
+ }
+ }
+ }
+
+ if (parseMessage) {
+ // parse commit message looking for fixes/closes #n
+ Pattern p = Pattern.compile("(?:fixes|closes)[\\s-]+#?(\\d+)", Pattern.CASE_INSENSITIVE);
+ Matcher m = p.matcher(commit.getFullMessage());
+ while (m.find()) {
+ String val = m.group();
+ return Long.parseLong(val);
+ }
+ }
+ return 0L;
+ }
+
+ private int countCommits(String baseId, String tipId) {
+ int count = 0;
+ RevWalk walk = getRevWalk();
+ walk.reset();
+ walk.sort(RevSort.TOPO);
+ walk.sort(RevSort.REVERSE, true);
+ try {
+ RevCommit tip = walk.parseCommit(getRepository().resolve(tipId));
+ RevCommit base = walk.parseCommit(getRepository().resolve(baseId));
+ walk.markStart(tip);
+ walk.markUninteresting(base);
+ for (;;) {
+ RevCommit c = walk.next();
+ if (c == null) {
+ break;
+ }
+ count++;
+ }
+ } catch (IOException e) {
+ // Should never happen, the core receive process would have
+ // identified the missing object earlier before we got control.
+ LOGGER.error("failed to get commit count", e);
+ return 0;
+ } finally {
+ walk.release();
+ }
+ return count;
+ }
+
+ /**
+ * Creates a new patchset with metadata.
+ *
+ * @param ticket
+ * @param mergeBase
+ * @param tip
+ */
+ private Patchset newPatchset(TicketModel ticket, String mergeBase, String tip) {
+ int totalCommits = countCommits(mergeBase, tip);
+
+ Patchset newPatchset = new Patchset();
+ newPatchset.tip = tip;
+ newPatchset.base = mergeBase;
+ newPatchset.commits = totalCommits;
+
+ Patchset currPatchset = ticket == null ? null : ticket.getCurrentPatchset();
+ if (currPatchset == null) {
+ /*
+ * PROPOSAL PATCHSET
+ * patchset 1, rev 1
+ */
+ newPatchset.number = 1;
+ newPatchset.rev = 1;
+ newPatchset.type = PatchsetType.Proposal;
+
+ // diffstat from merge base
+ DiffStat diffStat = DiffUtils.getDiffStat(getRepository(), mergeBase, tip);
+ newPatchset.insertions = diffStat.getInsertions();
+ newPatchset.deletions = diffStat.getDeletions();
+ } else {
+ /*
+ * PATCHSET UPDATE
+ */
+ int added = totalCommits - currPatchset.commits;
+ boolean ff = JGitUtils.isMergedInto(getRepository(), currPatchset.tip, tip);
+ boolean squash = added < 0;
+ boolean rebase = !currPatchset.base.equals(mergeBase);
+
+ // determine type, number and rev of the patchset
+ if (ff) {
+ /*
+ * FAST-FORWARD
+ * patchset number preserved, rev incremented
+ */
+
+ boolean merged = JGitUtils.isMergedInto(getRepository(), currPatchset.tip, ticket.mergeTo);
+ if (merged) {
+ // current patchset was already merged
+ // new patchset, mark as rebase
+ newPatchset.type = PatchsetType.Rebase;
+ newPatchset.number = currPatchset.number + 1;
+ newPatchset.rev = 1;
+
+ // diffstat from parent
+ DiffStat diffStat = DiffUtils.getDiffStat(getRepository(), mergeBase, tip);
+ newPatchset.insertions = diffStat.getInsertions();
+ newPatchset.deletions = diffStat.getDeletions();
+ } else {
+ // FF update to patchset
+ newPatchset.type = PatchsetType.FastForward;
+ newPatchset.number = currPatchset.number;
+ newPatchset.rev = currPatchset.rev + 1;
+ newPatchset.parent = currPatchset.tip;
+
+ // diffstat from parent
+ DiffStat diffStat = DiffUtils.getDiffStat(getRepository(), currPatchset.tip, tip);
+ newPatchset.insertions = diffStat.getInsertions();
+ newPatchset.deletions = diffStat.getDeletions();
+ }
+ } else {
+ /*
+ * NON-FAST-FORWARD
+ * new patchset, rev 1
+ */
+ if (rebase && squash) {
+ newPatchset.type = PatchsetType.Rebase_Squash;
+ newPatchset.number = currPatchset.number + 1;
+ newPatchset.rev = 1;
+ } else if (squash) {
+ newPatchset.type = PatchsetType.Squash;
+ newPatchset.number = currPatchset.number + 1;
+ newPatchset.rev = 1;
+ } else if (rebase) {
+ newPatchset.type = PatchsetType.Rebase;
+ newPatchset.number = currPatchset.number + 1;
+ newPatchset.rev = 1;
+ } else {
+ newPatchset.type = PatchsetType.Amend;
+ newPatchset.number = currPatchset.number + 1;
+ newPatchset.rev = 1;
+ }
+
+ // diffstat from merge base
+ DiffStat diffStat = DiffUtils.getDiffStat(getRepository(), mergeBase, tip);
+ newPatchset.insertions = diffStat.getInsertions();
+ newPatchset.deletions = diffStat.getDeletions();
+ }
+
+ if (added > 0) {
+ // ignore squash (negative add)
+ newPatchset.added = added;
+ }
+ }
+
+ return newPatchset;
+ }
+
+ private RefUpdate updateRef(String ref, ObjectId newId) {
+ ObjectId ticketRefId = ObjectId.zeroId();
+ try {
+ ticketRefId = getRepository().resolve(ref);
+ } catch (Exception e) {
+ // ignore
+ }
+
+ try {
+ RefUpdate ru = getRepository().updateRef(ref, false);
+ ru.setRefLogIdent(getRefLogIdent());
+ ru.setForceUpdate(true);
+ ru.setExpectedOldObjectId(ticketRefId);
+ ru.setNewObjectId(newId);
+ RefUpdate.Result result = ru.update(getRevWalk());
+ if (result == RefUpdate.Result.LOCK_FAILURE) {
+ sendError("Failed to obtain lock when updating {0}:{1}", repository.name, ref);
+ sendError("Perhaps an administrator should remove {0}/{1}.lock?", getRepository().getDirectory(), ref);
+ return null;
+ }
+ return ru;
+ } catch (IOException e) {
+ LOGGER.error("failed to update ref " + ref, e);
+ sendError("There was an error updating ref {0}:{1}", repository.name, ref);
+ }
+ return null;
+ }
+
+ private void updateReflog(RefUpdate ru) {
+ if (ru == null) {
+ return;
+ }
+
+ ReceiveCommand.Type type = null;
+ switch (ru.getResult()) {
+ case NEW:
+ type = Type.CREATE;
+ break;
+ case FAST_FORWARD:
+ type = Type.UPDATE;
+ break;
+ case FORCED:
+ type = Type.UPDATE_NONFASTFORWARD;
+ break;
+ default:
+ LOGGER.error(MessageFormat.format("unexpected ref update type {0} for {1}",
+ ru.getResult(), ru.getName()));
+ return;
+ }
+ ReceiveCommand cmd = new ReceiveCommand(ru.getOldObjectId(), ru.getNewObjectId(), ru.getName(), type);
+ RefLogUtils.updateRefLog(user, getRepository(), Arrays.asList(cmd));
+ }
+
+ /**
+ * Merge the specified patchset to the integration branch.
+ *
+ * @param ticket
+ * @param patchset
+ * @return true, if successful
+ */
+ public MergeStatus merge(TicketModel ticket) {
+ PersonIdent committer = new PersonIdent(user.getDisplayName(), StringUtils.isEmpty(user.emailAddress) ? (user.username + "@gitblit") : user.emailAddress);
+ Patchset patchset = ticket.getCurrentPatchset();
+ String message = MessageFormat.format("Merged #{0,number,0} \"{1}\"", ticket.number, ticket.title);
+ Ref oldRef = null;
+ try {
+ oldRef = getRepository().getRef(ticket.mergeTo);
+ } catch (IOException e) {
+ LOGGER.error("failed to get ref for " + ticket.mergeTo, e);
+ }
+ MergeResult mergeResult = JGitUtils.merge(
+ getRepository(),
+ patchset.tip,
+ ticket.mergeTo,
+ committer,
+ message);
+
+ if (StringUtils.isEmpty(mergeResult.sha)) {
+ LOGGER.error("FAILED to merge {} to {} ({})", new Object [] { patchset, ticket.mergeTo, mergeResult.status.name() });
+ return mergeResult.status;
+ }
+ Change change = new Change(user.username);
+ change.setField(Field.status, Status.Merged);
+ change.setField(Field.mergeSha, mergeResult.sha);
+ change.setField(Field.mergeTo, ticket.mergeTo);
+
+ if (StringUtils.isEmpty(ticket.responsible)) {
+ // unassigned tickets are assigned to the closer
+ change.setField(Field.responsible, user.username);
+ }
+
+ long ticketId = ticket.number;
+ ticket = ticketService.updateTicket(repository, ticket.number, change);
+ if (ticket != null) {
+ ticketNotifier.queueMailing(ticket);
+
+ // update the reflog with the merge
+ if (oldRef != null) {
+ ReceiveCommand cmd = new ReceiveCommand(oldRef.getObjectId(),
+ ObjectId.fromString(mergeResult.sha), oldRef.getName());
+ RefLogUtils.updateRefLog(user, getRepository(), Arrays.asList(cmd));
+ }
+ return mergeResult.status;
+ } else {
+ LOGGER.error("FAILED to resolve ticket {} by merge from web ui", ticketId);
+ }
+ return mergeResult.status;
+ }
+
+ public void sendAll() {
+ ticketNotifier.sendAll();
+ }
+} diff --git a/src/main/java/com/gitblit/manager/GitblitManager.java b/src/main/java/com/gitblit/manager/GitblitManager.java index 6eb60236..b27d650d 100644 --- a/src/main/java/com/gitblit/manager/GitblitManager.java +++ b/src/main/java/com/gitblit/manager/GitblitManager.java @@ -62,6 +62,7 @@ import com.gitblit.models.ServerStatus; import com.gitblit.models.SettingModel; import com.gitblit.models.TeamModel; import com.gitblit.models.UserModel; +import com.gitblit.tickets.ITicketService; import com.gitblit.utils.ArrayUtils; import com.gitblit.utils.HttpUtils; import com.gitblit.utils.JGitUtils; @@ -483,6 +484,15 @@ public class GitblitManager implements IGitblit { } } + /** + * Throws an exception if trying to get a ticket service. + * + */ + @Override + public ITicketService getTicketService() { + throw new RuntimeException("This class does not have a ticket service!"); + } + /* * ISTOREDSETTINGS * diff --git a/src/main/java/com/gitblit/manager/IGitblit.java b/src/main/java/com/gitblit/manager/IGitblit.java index aa091226..50210e9d 100644 --- a/src/main/java/com/gitblit/manager/IGitblit.java +++ b/src/main/java/com/gitblit/manager/IGitblit.java @@ -26,6 +26,7 @@ import com.gitblit.models.RepositoryModel; import com.gitblit.models.RepositoryUrl; import com.gitblit.models.TeamModel; import com.gitblit.models.UserModel; +import com.gitblit.tickets.ITicketService; public interface IGitblit extends IManager, IRuntimeManager, @@ -101,4 +102,11 @@ public interface IGitblit extends IManager, */ Collection<GitClientApplication> getClientApplications(); + /** + * Returns the ticket service. + * + * @return a ticket service + */ + ITicketService getTicketService(); + }
\ No newline at end of file diff --git a/src/main/java/com/gitblit/manager/RepositoryManager.java b/src/main/java/com/gitblit/manager/RepositoryManager.java index e412deba..1e917984 100644 --- a/src/main/java/com/gitblit/manager/RepositoryManager.java +++ b/src/main/java/com/gitblit/manager/RepositoryManager.java @@ -801,6 +801,9 @@ public class RepositoryManager implements IRepositoryManager { model.description = getConfig(config, "description", ""); model.originRepository = getConfig(config, "originRepository", null); model.addOwners(ArrayUtils.fromString(getConfig(config, "owner", ""))); + model.acceptNewPatchsets = getConfig(config, "acceptNewPatchsets", true); + model.acceptNewTickets = getConfig(config, "acceptNewTickets", true); + model.requireApproval = getConfig(config, "requireApproval", settings.getBoolean(Keys.tickets.requireApproval, false)); model.useIncrementalPushTags = getConfig(config, "useIncrementalPushTags", false); model.incrementalPushTagPrefix = getConfig(config, "incrementalPushTagPrefix", null); model.allowForks = getConfig(config, "allowForks", true); @@ -1406,6 +1409,15 @@ public class RepositoryManager implements IRepositoryManager { config.setString(Constants.CONFIG_GITBLIT, null, "description", repository.description); config.setString(Constants.CONFIG_GITBLIT, null, "originRepository", repository.originRepository); config.setString(Constants.CONFIG_GITBLIT, null, "owner", ArrayUtils.toString(repository.owners)); + config.setBoolean(Constants.CONFIG_GITBLIT, null, "acceptNewPatchsets", repository.acceptNewPatchsets); + config.setBoolean(Constants.CONFIG_GITBLIT, null, "acceptNewTickets", repository.acceptNewTickets); + if (settings.getBoolean(Keys.tickets.requireApproval, false) == repository.requireApproval) { + // use default + config.unset(Constants.CONFIG_GITBLIT, null, "requireApproval"); + } else { + // override default + config.setBoolean(Constants.CONFIG_GITBLIT, null, "requireApproval", repository.requireApproval); + } config.setBoolean(Constants.CONFIG_GITBLIT, null, "useIncrementalPushTags", repository.useIncrementalPushTags); if (StringUtils.isEmpty(repository.incrementalPushTagPrefix) || repository.incrementalPushTagPrefix.equals(settings.getString(Keys.git.defaultIncrementalPushTagPrefix, "r"))) { diff --git a/src/main/java/com/gitblit/models/RepositoryModel.java b/src/main/java/com/gitblit/models/RepositoryModel.java index b76e9bc6..5bd2ec03 100644 --- a/src/main/java/com/gitblit/models/RepositoryModel.java +++ b/src/main/java/com/gitblit/models/RepositoryModel.java @@ -85,6 +85,9 @@ public class RepositoryModel implements Serializable, Comparable<RepositoryModel public int maxActivityCommits;
public List<String> metricAuthorExclusions;
public CommitMessageRenderer commitMessageRenderer;
+ public boolean acceptNewPatchsets;
+ public boolean acceptNewTickets;
+ public boolean requireApproval; public transient boolean isCollectingGarbage;
public Date lastGC;
@@ -105,6 +108,8 @@ public class RepositoryModel implements Serializable, Comparable<RepositoryModel this.projectPath = StringUtils.getFirstPathElement(name);
this.owners = new ArrayList<String>();
this.isBare = true;
+ this.acceptNewTickets = true;
+ this.acceptNewPatchsets = true;
addOwner(owner);
}
@@ -140,6 +145,10 @@ public class RepositoryModel implements Serializable, Comparable<RepositoryModel displayName = null;
}
+ public String getRID() {
+ return StringUtils.getSHA1(name);
+ }
+
@Override
public int hashCode() {
return name.hashCode();
@@ -209,6 +218,8 @@ public class RepositoryModel implements Serializable, Comparable<RepositoryModel clone.federationStrategy = federationStrategy;
clone.showRemoteBranches = false;
clone.allowForks = false;
+ clone.acceptNewPatchsets = false;
+ clone.acceptNewTickets = false; clone.skipSizeCalculation = skipSizeCalculation;
clone.skipSummaryMetrics = skipSummaryMetrics;
clone.sparkleshareId = sparkleshareId;
diff --git a/src/main/java/com/gitblit/models/TicketModel.java b/src/main/java/com/gitblit/models/TicketModel.java new file mode 100644 index 00000000..1ff55ddb --- /dev/null +++ b/src/main/java/com/gitblit/models/TicketModel.java @@ -0,0 +1,1286 @@ +/* + * Copyright 2014 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.models; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.io.UnsupportedEncodingException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.eclipse.jgit.util.RelativeDateFormatter; + +/** + * The Gitblit Ticket model, its component classes, and enums. + * + * @author James Moger + * + */ +public class TicketModel implements Serializable, Comparable<TicketModel> { + + private static final long serialVersionUID = 1L; + + public String project; + + public String repository; + + public long number; + + public Date created; + + public String createdBy; + + public Date updated; + + public String updatedBy; + + public String title; + + public String body; + + public String topic; + + public Type type; + + public Status status; + + public String responsible; + + public String milestone; + + public String mergeSha; + + public String mergeTo; + + public List<Change> changes; + + public Integer insertions; + + public Integer deletions; + + /** + * Builds an effective ticket from the collection of changes. A change may + * Add or Subtract information from a ticket, but the collection of changes + * is only additive. + * + * @param changes + * @return the effective ticket + */ + public static TicketModel buildTicket(Collection<Change> changes) { + TicketModel ticket; + List<Change> effectiveChanges = new ArrayList<Change>(); + Map<String, Change> comments = new HashMap<String, Change>(); + for (Change change : changes) { + if (change.comment != null) { + if (comments.containsKey(change.comment.id)) { + Change original = comments.get(change.comment.id); + Change clone = copy(original); + clone.comment.text = change.comment.text; + clone.comment.deleted = change.comment.deleted; + int idx = effectiveChanges.indexOf(original); + effectiveChanges.remove(original); + effectiveChanges.add(idx, clone); + comments.put(clone.comment.id, clone); + } else { + effectiveChanges.add(change); + comments.put(change.comment.id, change); + } + } else { + effectiveChanges.add(change); + } + } + + // effective ticket + ticket = new TicketModel(); + for (Change change : effectiveChanges) { + if (!change.hasComment()) { + // ensure we do not include a deleted comment + change.comment = null; + } + ticket.applyChange(change); + } + return ticket; + } + + public TicketModel() { + // the first applied change set the date appropriately + created = new Date(0); + changes = new ArrayList<Change>(); + status = Status.New; + type = Type.defaultType; + } + + public boolean isOpen() { + return !status.isClosed(); + } + + public boolean isClosed() { + return status.isClosed(); + } + + public boolean isMerged() { + return isClosed() && !isEmpty(mergeSha); + } + + public boolean isProposal() { + return Type.Proposal == type; + } + + public boolean isBug() { + return Type.Bug == type; + } + + public Date getLastUpdated() { + return updated == null ? created : updated; + } + + public boolean hasPatchsets() { + return getPatchsets().size() > 0; + } + + /** + * Returns true if multiple participants are involved in discussing a ticket. + * The ticket creator is excluded from this determination because a + * discussion requires more than one participant. + * + * @return true if this ticket has a discussion + */ + public boolean hasDiscussion() { + for (Change change : getComments()) { + if (!change.author.equals(createdBy)) { + return true; + } + } + return false; + } + + /** + * Returns the list of changes with comments. + * + * @return + */ + public List<Change> getComments() { + List<Change> list = new ArrayList<Change>(); + for (Change change : changes) { + if (change.hasComment()) { + list.add(change); + } + } + return list; + } + + /** + * Returns the list of participants for the ticket. + * + * @return the list of participants + */ + public List<String> getParticipants() { + Set<String> set = new LinkedHashSet<String>(); + for (Change change : changes) { + if (change.isParticipantChange()) { + set.add(change.author); + } + } + if (responsible != null && responsible.length() > 0) { + set.add(responsible); + } + return new ArrayList<String>(set); + } + + public boolean hasLabel(String label) { + return getLabels().contains(label); + } + + public List<String> getLabels() { + return getList(Field.labels); + } + + public boolean isResponsible(String username) { + return username.equals(responsible); + } + + public boolean isAuthor(String username) { + return username.equals(createdBy); + } + + public boolean isReviewer(String username) { + return getReviewers().contains(username); + } + + public List<String> getReviewers() { + return getList(Field.reviewers); + } + + public boolean isWatching(String username) { + return getWatchers().contains(username); + } + + public List<String> getWatchers() { + return getList(Field.watchers); + } + + public boolean isVoter(String username) { + return getVoters().contains(username); + } + + public List<String> getVoters() { + return getList(Field.voters); + } + + public List<String> getMentions() { + return getList(Field.mentions); + } + + protected List<String> getList(Field field) { + Set<String> set = new TreeSet<String>(); + for (Change change : changes) { + if (change.hasField(field)) { + String values = change.getString(field); + for (String value : values.split(",")) { + switch (value.charAt(0)) { + case '+': + set.add(value.substring(1)); + break; + case '-': + set.remove(value.substring(1)); + break; + default: + set.add(value); + } + } + } + } + if (!set.isEmpty()) { + return new ArrayList<String>(set); + } + return Collections.emptyList(); + } + + public Attachment getAttachment(String name) { + Attachment attachment = null; + for (Change change : changes) { + if (change.hasAttachments()) { + Attachment a = change.getAttachment(name); + if (a != null) { + attachment = a; + } + } + } + return attachment; + } + + public boolean hasAttachments() { + for (Change change : changes) { + if (change.hasAttachments()) { + return true; + } + } + return false; + } + + public List<Attachment> getAttachments() { + List<Attachment> list = new ArrayList<Attachment>(); + for (Change change : changes) { + if (change.hasAttachments()) { + list.addAll(change.attachments); + } + } + return list; + } + + public List<Patchset> getPatchsets() { + List<Patchset> list = new ArrayList<Patchset>(); + for (Change change : changes) { + if (change.patchset != null) { + list.add(change.patchset); + } + } + return list; + } + + public List<Patchset> getPatchsetRevisions(int number) { + List<Patchset> list = new ArrayList<Patchset>(); + for (Change change : changes) { + if (change.patchset != null) { + if (number == change.patchset.number) { + list.add(change.patchset); + } + } + } + return list; + } + + public Patchset getPatchset(String sha) { + for (Change change : changes) { + if (change.patchset != null) { + if (sha.equals(change.patchset.tip)) { + return change.patchset; + } + } + } + return null; + } + + public Patchset getPatchset(int number, int rev) { + for (Change change : changes) { + if (change.patchset != null) { + if (number == change.patchset.number && rev == change.patchset.rev) { + return change.patchset; + } + } + } + return null; + } + + public Patchset getCurrentPatchset() { + Patchset patchset = null; + for (Change change : changes) { + if (change.patchset != null) { + if (patchset == null) { + patchset = change.patchset; + } else if (patchset.compareTo(change.patchset) == 1) { + patchset = change.patchset; + } + } + } + return patchset; + } + + public boolean isCurrent(Patchset patchset) { + if (patchset == null) { + return false; + } + Patchset curr = getCurrentPatchset(); + if (curr == null) { + return false; + } + return curr.equals(patchset); + } + + public List<Change> getReviews(Patchset patchset) { + if (patchset == null) { + return Collections.emptyList(); + } + // collect the patchset reviews by author + // the last review by the author is the + // official review + Map<String, Change> reviews = new LinkedHashMap<String, TicketModel.Change>(); + for (Change change : changes) { + if (change.hasReview()) { + if (change.review.isReviewOf(patchset)) { + reviews.put(change.author, change); + } + } + } + return new ArrayList<Change>(reviews.values()); + } + + + public boolean isApproved(Patchset patchset) { + if (patchset == null) { + return false; + } + boolean approved = false; + boolean vetoed = false; + for (Change change : getReviews(patchset)) { + if (change.hasReview()) { + if (change.review.isReviewOf(patchset)) { + if (Score.approved == change.review.score) { + approved = true; + } else if (Score.vetoed == change.review.score) { + vetoed = true; + } + } + } + } + return approved && !vetoed; + } + + public boolean isVetoed(Patchset patchset) { + if (patchset == null) { + return false; + } + for (Change change : getReviews(patchset)) { + if (change.hasReview()) { + if (change.review.isReviewOf(patchset)) { + if (Score.vetoed == change.review.score) { + return true; + } + } + } + } + return false; + } + + public Review getReviewBy(String username) { + for (Change change : getReviews(getCurrentPatchset())) { + if (change.author.equals(username)) { + return change.review; + } + } + return null; + } + + public boolean isPatchsetAuthor(String username) { + for (Change change : changes) { + if (change.hasPatchset()) { + if (change.author.equals(username)) { + return true; + } + } + } + return false; + } + + public void applyChange(Change change) { + if (changes.size() == 0) { + // first change created the ticket + created = change.date; + createdBy = change.author; + status = Status.New; + } else if (created == null || change.date.after(created)) { + // track last ticket update + updated = change.date; + updatedBy = change.author; + } + + if (change.isMerge()) { + // identify merge patchsets + if (isEmpty(responsible)) { + responsible = change.author; + } + status = Status.Merged; + } + + if (change.hasFieldChanges()) { + for (Map.Entry<Field, String> entry : change.fields.entrySet()) { + Field field = entry.getKey(); + Object value = entry.getValue(); + switch (field) { + case type: + type = TicketModel.Type.fromObject(value, type); + break; + case status: + status = TicketModel.Status.fromObject(value, status); + break; + case title: + title = toString(value); + break; + case body: + body = toString(value); + break; + case topic: + topic = toString(value); + break; + case responsible: + responsible = toString(value); + break; + case milestone: + milestone = toString(value); + break; + case mergeTo: + mergeTo = toString(value); + break; + case mergeSha: + mergeSha = toString(value); + break; + default: + // unknown + break; + } + } + } + + // add the change to the ticket + changes.add(change); + } + + protected String toString(Object value) { + if (value == null) { + return null; + } + return value.toString(); + } + + public String toIndexableString() { + StringBuilder sb = new StringBuilder(); + if (!isEmpty(title)) { + sb.append(title).append('\n'); + } + if (!isEmpty(body)) { + sb.append(body).append('\n'); + } + for (Change change : changes) { + if (change.hasComment()) { + sb.append(change.comment.text); + sb.append('\n'); + } + } + return sb.toString(); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("#"); + sb.append(number); + sb.append(": " + title + "\n"); + for (Change change : changes) { + sb.append(change); + sb.append('\n'); + } + return sb.toString(); + } + + @Override + public int compareTo(TicketModel o) { + return o.created.compareTo(created); + } + + @Override + public boolean equals(Object o) { + if (o instanceof TicketModel) { + return number == ((TicketModel) o).number; + } + return super.equals(o); + } + + @Override + public int hashCode() { + return (repository + number).hashCode(); + } + + /** + * Encapsulates a ticket change + */ + public static class Change implements Serializable, Comparable<Change> { + + private static final long serialVersionUID = 1L; + + public final Date date; + + public final String author; + + public Comment comment; + + public Map<Field, String> fields; + + public Set<Attachment> attachments; + + public Patchset patchset; + + public Review review; + + private transient String id; + + public Change(String author) { + this(author, new Date()); + } + + public Change(String author, Date date) { + this.date = date; + this.author = author; + } + + public boolean isStatusChange() { + return hasField(Field.status); + } + + public Status getStatus() { + Status state = Status.fromObject(getField(Field.status), null); + return state; + } + + public boolean isMerge() { + return hasField(Field.status) && hasField(Field.mergeSha); + } + + public boolean hasPatchset() { + return patchset != null; + } + + public boolean hasReview() { + return review != null; + } + + public boolean hasComment() { + return comment != null && !comment.isDeleted(); + } + + public Comment comment(String text) { + comment = new Comment(text); + comment.id = TicketModel.getSHA1(date.toString() + author + text); + + try { + Pattern mentions = Pattern.compile("\\s@([A-Za-z0-9-_]+)"); + Matcher m = mentions.matcher(text); + while (m.find()) { + String username = m.group(1); + plusList(Field.mentions, username); + } + } catch (Exception e) { + // ignore + } + return comment; + } + + public Review review(Patchset patchset, Score score, boolean addReviewer) { + if (addReviewer) { + plusList(Field.reviewers, author); + } + review = new Review(patchset.number, patchset.rev); + review.score = score; + return review; + } + + public boolean hasAttachments() { + return !TicketModel.isEmpty(attachments); + } + + public void addAttachment(Attachment attachment) { + if (attachments == null) { + attachments = new LinkedHashSet<Attachment>(); + } + attachments.add(attachment); + } + + public Attachment getAttachment(String name) { + if (attachments != null) { + for (Attachment attachment : attachments) { + if (attachment.name.equalsIgnoreCase(name)) { + return attachment; + } + } + } + return null; + } + + public boolean isParticipantChange() { + if (hasComment() + || hasReview() + || hasPatchset() + || hasAttachments()) { + return true; + } + + if (TicketModel.isEmpty(fields)) { + return false; + } + + // identify real ticket field changes + Map<Field, String> map = new HashMap<Field, String>(fields); + map.remove(Field.watchers); + map.remove(Field.voters); + return !map.isEmpty(); + } + + public boolean hasField(Field field) { + return !TicketModel.isEmpty(getString(field)); + } + + public boolean hasFieldChanges() { + return !TicketModel.isEmpty(fields); + } + + public String getField(Field field) { + if (fields != null) { + return fields.get(field); + } + return null; + } + + public void setField(Field field, Object value) { + if (fields == null) { + fields = new LinkedHashMap<Field, String>(); + } + if (value == null) { + fields.put(field, null); + } else if (Enum.class.isAssignableFrom(value.getClass())) { + fields.put(field, ((Enum<?>) value).name()); + } else { + fields.put(field, value.toString()); + } + } + + public void remove(Field field) { + if (fields != null) { + fields.remove(field); + } + } + + public String getString(Field field) { + String value = getField(field); + if (value == null) { + return null; + } + return value; + } + + public void watch(String... username) { + plusList(Field.watchers, username); + } + + public void unwatch(String... username) { + minusList(Field.watchers, username); + } + + public void vote(String... username) { + plusList(Field.voters, username); + } + + public void unvote(String... username) { + minusList(Field.voters, username); + } + + public void label(String... label) { + plusList(Field.labels, label); + } + + public void unlabel(String... label) { + minusList(Field.labels, label); + } + + protected void plusList(Field field, String... items) { + modList(field, "+", items); + } + + protected void minusList(Field field, String... items) { + modList(field, "-", items); + } + + private void modList(Field field, String prefix, String... items) { + List<String> list = new ArrayList<String>(); + for (String item : items) { + list.add(prefix + item); + } + setField(field, join(list, ",")); + } + + public String getId() { + if (id == null) { + id = getSHA1(Long.toHexString(date.getTime()) + author); + } + return id; + } + + @Override + public int compareTo(Change c) { + return date.compareTo(c.date); + } + + @Override + public int hashCode() { + return getId().hashCode(); + } + + @Override + public boolean equals(Object o) { + if (o instanceof Change) { + return getId().equals(((Change) o).getId()); + } + return false; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append(RelativeDateFormatter.format(date)); + if (hasComment()) { + sb.append(" commented on by "); + } else if (hasPatchset()) { + sb.append(MessageFormat.format(" {0} uploaded by ", patchset)); + } else { + sb.append(" changed by "); + } + sb.append(author).append(" - "); + if (hasComment()) { + if (comment.isDeleted()) { + sb.append("(deleted) "); + } + sb.append(comment.text).append(" "); + } + + if (hasFieldChanges()) { + for (Map.Entry<Field, String> entry : fields.entrySet()) { + sb.append("\n "); + sb.append(entry.getKey().name()); + sb.append(':'); + sb.append(entry.getValue()); + } + } + return sb.toString(); + } + } + + /** + * Returns true if the string is null or empty. + * + * @param value + * @return true if string is null or empty + */ + static boolean isEmpty(String value) { + return value == null || value.trim().length() == 0; + } + + /** + * Returns true if the collection is null or empty + * + * @param collection + * @return + */ + static boolean isEmpty(Collection<?> collection) { + return collection == null || collection.size() == 0; + } + + /** + * Returns true if the map is null or empty + * + * @param map + * @return + */ + static boolean isEmpty(Map<?, ?> map) { + return map == null || map.size() == 0; + } + + /** + * Calculates the SHA1 of the string. + * + * @param text + * @return sha1 of the string + */ + static String getSHA1(String text) { + try { + byte[] bytes = text.getBytes("iso-8859-1"); + return getSHA1(bytes); + } catch (UnsupportedEncodingException u) { + throw new RuntimeException(u); + } + } + + /** + * Calculates the SHA1 of the byte array. + * + * @param bytes + * @return sha1 of the byte array + */ + static String getSHA1(byte[] bytes) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + md.update(bytes, 0, bytes.length); + byte[] digest = md.digest(); + return toHex(digest); + } catch (NoSuchAlgorithmException t) { + throw new RuntimeException(t); + } + } + + /** + * Returns the hex representation of the byte array. + * + * @param bytes + * @return byte array as hex string + */ + static String toHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (int i = 0; i < bytes.length; i++) { + if ((bytes[i] & 0xff) < 0x10) { + sb.append('0'); + } + sb.append(Long.toString(bytes[i] & 0xff, 16)); + } + return sb.toString(); + } + + /** + * Join the list of strings into a single string with a space separator. + * + * @param values + * @return joined list + */ + static String join(Collection<String> values) { + return join(values, " "); + } + + /** + * Join the list of strings into a single string with the specified + * separator. + * + * @param values + * @param separator + * @return joined list + */ + static String join(String[] values, String separator) { + return join(Arrays.asList(values), separator); + } + + /** + * Join the list of strings into a single string with the specified + * separator. + * + * @param values + * @param separator + * @return joined list + */ + static String join(Collection<String> values, String separator) { + StringBuilder sb = new StringBuilder(); + for (String value : values) { + sb.append(value).append(separator); + } + if (sb.length() > 0) { + // truncate trailing separator + sb.setLength(sb.length() - separator.length()); + } + return sb.toString().trim(); + } + + + /** + * Produce a deep copy of the given object. Serializes the entire object to + * a byte array in memory. Recommended for relatively small objects. + */ + @SuppressWarnings("unchecked") + static <T> T copy(T original) { + T o = null; + try { + ByteArrayOutputStream byteOut = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(byteOut); + oos.writeObject(original); + ByteArrayInputStream byteIn = new ByteArrayInputStream(byteOut.toByteArray()); + ObjectInputStream ois = new ObjectInputStream(byteIn); + try { + o = (T) ois.readObject(); + } catch (ClassNotFoundException cex) { + // actually can not happen in this instance + } + } catch (IOException iox) { + // doesn't seem likely to happen as these streams are in memory + throw new RuntimeException(iox); + } + return o; + } + + public static class Patchset implements Serializable, Comparable<Patchset> { + + private static final long serialVersionUID = 1L; + + public int number; + public int rev; + public String tip; + public String parent; + public String base; + public int insertions; + public int deletions; + public int commits; + public int added; + public PatchsetType type; + + public boolean isFF() { + return PatchsetType.FastForward == type; + } + + @Override + public int hashCode() { + return toString().hashCode(); + } + + @Override + public boolean equals(Object o) { + if (o instanceof Patchset) { + return hashCode() == o.hashCode(); + } + return false; + } + + @Override + public int compareTo(Patchset p) { + if (number > p.number) { + return -1; + } else if (p.number > number) { + return 1; + } else { + // same patchset, different revision + if (rev > p.rev) { + return -1; + } else if (p.rev > rev) { + return 1; + } else { + // same patchset & revision + return 0; + } + } + } + + @Override + public String toString() { + return "patchset " + number + " revision " + rev; + } + } + + public static class Comment implements Serializable { + + private static final long serialVersionUID = 1L; + + public String text; + + public String id; + + public Boolean deleted; + + public CommentSource src; + + public String replyTo; + + Comment(String text) { + this.text = text; + } + + public boolean isDeleted() { + return deleted != null && deleted; + } + + @Override + public String toString() { + return text; + } + } + + public static class Attachment implements Serializable { + + private static final long serialVersionUID = 1L; + + public final String name; + public long size; + public byte[] content; + public Boolean deleted; + + public Attachment(String name) { + this.name = name; + } + + public boolean isDeleted() { + return deleted != null && deleted; + } + + @Override + public int hashCode() { + return name.hashCode(); + } + + @Override + public boolean equals(Object o) { + if (o instanceof Attachment) { + return name.equalsIgnoreCase(((Attachment) o).name); + } + return false; + } + + @Override + public String toString() { + return name; + } + } + + public static class Review implements Serializable { + + private static final long serialVersionUID = 1L; + + public final int patchset; + + public final int rev; + + public Score score; + + public Review(int patchset, int revision) { + this.patchset = patchset; + this.rev = revision; + } + + public boolean isReviewOf(Patchset p) { + return patchset == p.number && rev == p.rev; + } + + @Override + public String toString() { + return "review of patchset " + patchset + " rev " + rev + ":" + score; + } + } + + public static enum Score { + approved(2), looks_good(1), not_reviewed(0), needs_improvement(-1), vetoed(-2); + + final int value; + + Score(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + + @Override + public String toString() { + return name().toLowerCase().replace('_', ' '); + } + } + + public static enum Field { + title, body, responsible, type, status, milestone, mergeSha, mergeTo, + topic, labels, watchers, reviewers, voters, mentions; + } + + public static enum Type { + Enhancement, Task, Bug, Proposal, Question; + + public static Type defaultType = Task; + + public static Type [] choices() { + return new Type [] { Enhancement, Task, Bug, Question }; + } + + @Override + public String toString() { + return name().toLowerCase().replace('_', ' '); + } + + public static Type fromObject(Object o, Type defaultType) { + if (o instanceof Type) { + // cast and return + return (Type) o; + } else if (o instanceof String) { + // find by name + for (Type type : values()) { + String str = o.toString(); + if (type.name().equalsIgnoreCase(str) + || type.toString().equalsIgnoreCase(str)) { + return type; + } + } + } else if (o instanceof Number) { + // by ordinal + int id = ((Number) o).intValue(); + if (id >= 0 && id < values().length) { + return values()[id]; + } + } + + return defaultType; + } + } + + public static enum Status { + New, Open, Resolved, Fixed, Merged, Wontfix, Declined, Duplicate, Invalid, On_Hold; + + public static Status [] requestWorkflow = { Open, Resolved, Declined, Duplicate, Invalid, On_Hold }; + + public static Status [] bugWorkflow = { Open, Fixed, Wontfix, Duplicate, Invalid, On_Hold }; + + public static Status [] proposalWorkflow = { Open, Declined, On_Hold}; + + @Override + public String toString() { + return name().toLowerCase().replace('_', ' '); + } + + public static Status fromObject(Object o, Status defaultStatus) { + if (o instanceof Status) { + // cast and return + return (Status) o; + } else if (o instanceof String) { + // find by name + String name = o.toString(); + for (Status state : values()) { + if (state.name().equalsIgnoreCase(name) + || state.toString().equalsIgnoreCase(name)) { + return state; + } + } + } else if (o instanceof Number) { + // by ordinal + int id = ((Number) o).intValue(); + if (id >= 0 && id < values().length) { + return values()[id]; + } + } + + return defaultStatus; + } + + public boolean isClosed() { + return ordinal() > Open.ordinal(); + } + } + + public static enum CommentSource { + Comment, Email + } + + public static enum PatchsetType { + Proposal, FastForward, Rebase, Squash, Rebase_Squash, Amend; + + public boolean isRewrite() { + return (this != FastForward) && (this != Proposal); + } + + @Override + public String toString() { + return name().toLowerCase().replace('_', '+'); + } + + public static PatchsetType fromObject(Object o) { + if (o instanceof PatchsetType) { + // cast and return + return (PatchsetType) o; + } else if (o instanceof String) { + // find by name + String name = o.toString(); + for (PatchsetType type : values()) { + if (type.name().equalsIgnoreCase(name) + || type.toString().equalsIgnoreCase(name)) { + return type; + } + } + } else if (o instanceof Number) { + // by ordinal + int id = ((Number) o).intValue(); + if (id >= 0 && id < values().length) { + return values()[id]; + } + } + + return null; + } + } +} diff --git a/src/main/java/com/gitblit/models/UserModel.java b/src/main/java/com/gitblit/models/UserModel.java index 6419cce9..63208f35 100644 --- a/src/main/java/com/gitblit/models/UserModel.java +++ b/src/main/java/com/gitblit/models/UserModel.java @@ -446,6 +446,18 @@ public class UserModel implements Principal, Serializable, Comparable<UserModel> return canAdmin() || model.isUsersPersonalRepository(username) || model.isOwner(username);
}
+ public boolean canReviewPatchset(RepositoryModel model) {
+ return isAuthenticated && canClone(model);
+ }
+
+ public boolean canApprovePatchset(RepositoryModel model) {
+ return isAuthenticated && canPush(model);
+ }
+
+ public boolean canVetoPatchset(RepositoryModel model) {
+ return isAuthenticated && canPush(model);
+ }
+
/**
* This returns true if the user has fork privileges or the user has fork
* privileges because of a team membership.
diff --git a/src/main/java/com/gitblit/servlet/PtServlet.java b/src/main/java/com/gitblit/servlet/PtServlet.java new file mode 100644 index 00000000..e9cbaa5b --- /dev/null +++ b/src/main/java/com/gitblit/servlet/PtServlet.java @@ -0,0 +1,201 @@ +/*
+ * Copyright 2014 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.servlet;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
+import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
+import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
+import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
+import org.apache.commons.compress.compressors.CompressorOutputStream;
+import org.apache.commons.compress.compressors.CompressorStreamFactory;
+import org.apache.wicket.util.io.ByteArrayOutputStream;
+import org.eclipse.jgit.lib.FileMode;
+
+import com.gitblit.dagger.DaggerServlet;
+import com.gitblit.manager.IRuntimeManager;
+
+import dagger.ObjectGraph;
+
+/**
+ * Handles requests for the Barnum pt (patchset tool).
+ *
+ * The user-agent determines the content and compression format.
+ *
+ * @author James Moger
+ *
+ */
+public class PtServlet extends DaggerServlet {
+
+ private static final long serialVersionUID = 1L;
+
+ private static final long lastModified = System.currentTimeMillis();
+
+ private IRuntimeManager runtimeManager;
+
+ @Override
+ protected void inject(ObjectGraph dagger) {
+ this.runtimeManager = dagger.get(IRuntimeManager.class);
+ }
+
+ @Override
+ protected long getLastModified(HttpServletRequest req) {
+ File file = runtimeManager.getFileOrFolder("tickets.pt", "${baseFolder}/pt.py");
+ if (file.exists()) {
+ return Math.max(lastModified, file.lastModified());
+ } else {
+ return lastModified;
+ }
+ }
+
+ @Override
+ protected void doGet(HttpServletRequest request, HttpServletResponse response)
+ throws ServletException, IOException {
+ try {
+ response.setContentType("application/octet-stream");
+ response.setDateHeader("Last-Modified", lastModified);
+ response.setHeader("Cache-Control", "none");
+ response.setHeader("Pragma", "no-cache");
+ response.setDateHeader("Expires", 0);
+
+ boolean windows = false;
+ try {
+ String useragent = request.getHeader("user-agent").toString();
+ windows = useragent.toLowerCase().contains("windows");
+ } catch (Exception e) {
+ }
+
+ byte[] pyBytes;
+ File file = runtimeManager.getFileOrFolder("tickets.pt", "${baseFolder}/pt.py");
+ if (file.exists()) {
+ // custom script
+ pyBytes = readAll(new FileInputStream(file));
+ } else {
+ // default script
+ pyBytes = readAll(getClass().getResourceAsStream("/pt.py"));
+ }
+
+ if (windows) {
+ // windows: download zip file with pt.py and pt.cmd
+ response.setHeader("Content-Disposition", "attachment; filename=\"pt.zip\"");
+
+ OutputStream os = response.getOutputStream();
+ ZipArchiveOutputStream zos = new ZipArchiveOutputStream(os);
+
+ // add the Python script
+ ZipArchiveEntry pyEntry = new ZipArchiveEntry("pt.py");
+ pyEntry.setSize(pyBytes.length);
+ pyEntry.setUnixMode(FileMode.EXECUTABLE_FILE.getBits());
+ pyEntry.setTime(lastModified);
+ zos.putArchiveEntry(pyEntry);
+ zos.write(pyBytes);
+ zos.closeArchiveEntry();
+
+ // add a Python launch cmd file
+ byte [] cmdBytes = readAll(getClass().getResourceAsStream("/pt.cmd"));
+ ZipArchiveEntry cmdEntry = new ZipArchiveEntry("pt.cmd");
+ cmdEntry.setSize(cmdBytes.length);
+ cmdEntry.setUnixMode(FileMode.REGULAR_FILE.getBits());
+ cmdEntry.setTime(lastModified);
+ zos.putArchiveEntry(cmdEntry);
+ zos.write(cmdBytes);
+ zos.closeArchiveEntry();
+
+ // add a brief readme
+ byte [] txtBytes = readAll(getClass().getResourceAsStream("/pt.txt"));
+ ZipArchiveEntry txtEntry = new ZipArchiveEntry("readme.txt");
+ txtEntry.setSize(txtBytes.length);
+ txtEntry.setUnixMode(FileMode.REGULAR_FILE.getBits());
+ txtEntry.setTime(lastModified);
+ zos.putArchiveEntry(txtEntry);
+ zos.write(txtBytes);
+ zos.closeArchiveEntry();
+
+ // cleanup
+ zos.finish();
+ zos.close();
+ os.flush();
+ } else {
+ // unix: download a tar.gz file with pt.py set with execute permissions
+ response.setHeader("Content-Disposition", "attachment; filename=\"pt.tar.gz\"");
+
+ OutputStream os = response.getOutputStream();
+ CompressorOutputStream cos = new CompressorStreamFactory().createCompressorOutputStream(CompressorStreamFactory.GZIP, os);
+ TarArchiveOutputStream tos = new TarArchiveOutputStream(cos);
+ tos.setAddPaxHeadersForNonAsciiNames(true);
+ tos.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX);
+
+ // add the Python script
+ TarArchiveEntry pyEntry = new TarArchiveEntry("pt");
+ pyEntry.setMode(FileMode.EXECUTABLE_FILE.getBits());
+ pyEntry.setModTime(lastModified);
+ pyEntry.setSize(pyBytes.length);
+ tos.putArchiveEntry(pyEntry);
+ tos.write(pyBytes);
+ tos.closeArchiveEntry();
+
+ // add a brief readme
+ byte [] txtBytes = readAll(getClass().getResourceAsStream("/pt.txt"));
+ TarArchiveEntry txtEntry = new TarArchiveEntry("README");
+ txtEntry.setMode(FileMode.REGULAR_FILE.getBits());
+ txtEntry.setModTime(lastModified);
+ txtEntry.setSize(txtBytes.length);
+ tos.putArchiveEntry(txtEntry);
+ tos.write(txtBytes);
+ tos.closeArchiveEntry();
+
+ // cleanup
+ tos.finish();
+ tos.close();
+ cos.close();
+ os.flush();
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ byte [] readAll(InputStream is) {
+ ByteArrayOutputStream os = new ByteArrayOutputStream();
+ try {
+ byte [] buffer = new byte[4096];
+ int len = 0;
+ while ((len = is.read(buffer)) > -1) {
+ os.write(buffer, 0, len);
+ }
+ return os.toByteArray();
+ } catch (IOException e) {
+ e.printStackTrace();
+ } finally {
+ try {
+ os.close();
+ is.close();
+ } catch (Exception e) {
+ // ignore
+ }
+ }
+ return new byte[0];
+ }
+}
diff --git a/src/main/java/com/gitblit/tickets/BranchTicketService.java b/src/main/java/com/gitblit/tickets/BranchTicketService.java new file mode 100644 index 00000000..14ed8094 --- /dev/null +++ b/src/main/java/com/gitblit/tickets/BranchTicketService.java @@ -0,0 +1,799 @@ +/* + * Copyright 2014 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.tickets; + +import java.io.IOException; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +import javax.inject.Inject; + +import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException; +import org.eclipse.jgit.api.errors.JGitInternalException; +import org.eclipse.jgit.dircache.DirCache; +import org.eclipse.jgit.dircache.DirCacheBuilder; +import org.eclipse.jgit.dircache.DirCacheEntry; +import org.eclipse.jgit.internal.JGitText; +import org.eclipse.jgit.lib.CommitBuilder; +import org.eclipse.jgit.lib.FileMode; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectInserter; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.RefUpdate; +import org.eclipse.jgit.lib.RefUpdate.Result; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevTree; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.treewalk.CanonicalTreeParser; +import org.eclipse.jgit.treewalk.TreeWalk; + +import com.gitblit.Constants; +import com.gitblit.manager.INotificationManager; +import com.gitblit.manager.IRepositoryManager; +import com.gitblit.manager.IRuntimeManager; +import com.gitblit.manager.IUserManager; +import com.gitblit.models.PathModel; +import com.gitblit.models.RefModel; +import com.gitblit.models.RepositoryModel; +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Attachment; +import com.gitblit.models.TicketModel.Change; +import com.gitblit.utils.ArrayUtils; +import com.gitblit.utils.JGitUtils; +import com.gitblit.utils.StringUtils; + +/** + * Implementation of a ticket service based on an orphan branch. All tickets + * are serialized as a list of JSON changes and persisted in a hashed directory + * structure, similar to the standard git loose object structure. + * + * @author James Moger + * + */ +public class BranchTicketService extends ITicketService { + + public static final String BRANCH = "refs/gitblit/tickets"; + + private static final String JOURNAL = "journal.json"; + + private static final String ID_PATH = "id/"; + + private final Map<String, AtomicLong> lastAssignedId; + + @Inject + public BranchTicketService( + IRuntimeManager runtimeManager, + INotificationManager notificationManager, + IUserManager userManager, + IRepositoryManager repositoryManager) { + + super(runtimeManager, + notificationManager, + userManager, + repositoryManager); + + lastAssignedId = new ConcurrentHashMap<String, AtomicLong>(); + } + + @Override + public BranchTicketService start() { + return this; + } + + @Override + protected void resetCachesImpl() { + lastAssignedId.clear(); + } + + @Override + protected void resetCachesImpl(RepositoryModel repository) { + if (lastAssignedId.containsKey(repository.name)) { + lastAssignedId.get(repository.name).set(0); + } + } + + @Override + protected void close() { + } + + /** + * Returns a RefModel for the refs/gitblit/tickets branch in the repository. + * If the branch can not be found, null is returned. + * + * @return a refmodel for the gitblit tickets branch or null + */ + private RefModel getTicketsBranch(Repository db) { + List<RefModel> refs = JGitUtils.getRefs(db, Constants.R_GITBLIT); + for (RefModel ref : refs) { + if (ref.reference.getName().equals(BRANCH)) { + return ref; + } + } + return null; + } + + /** + * Creates the refs/gitblit/tickets branch. + * @param db + */ + private void createTicketsBranch(Repository db) { + JGitUtils.createOrphanBranch(db, BRANCH, null); + } + + /** + * Returns the ticket path. This follows the same scheme as Git's object + * store path where the first two characters of the hash id are the root + * folder with the remaining characters as a subfolder within that folder. + * + * @param ticketId + * @return the root path of the ticket content on the refs/gitblit/tickets branch + */ + private String toTicketPath(long ticketId) { + StringBuilder sb = new StringBuilder(); + sb.append(ID_PATH); + long m = ticketId % 100L; + if (m < 10) { + sb.append('0'); + } + sb.append(m); + sb.append('/'); + sb.append(ticketId); + return sb.toString(); + } + + /** + * Returns the path to the attachment for the specified ticket. + * + * @param ticketId + * @param filename + * @return the path to the specified attachment + */ + private String toAttachmentPath(long ticketId, String filename) { + return toTicketPath(ticketId) + "/attachments/" + filename; + } + + /** + * Reads a file from the tickets branch. + * + * @param db + * @param file + * @return the file content or null + */ + private String readTicketsFile(Repository db, String file) { + RevWalk rw = null; + try { + ObjectId treeId = db.resolve(BRANCH + "^{tree}"); + if (treeId == null) { + return null; + } + rw = new RevWalk(db); + RevTree tree = rw.lookupTree(treeId); + if (tree != null) { + return JGitUtils.getStringContent(db, tree, file, Constants.ENCODING); + } + } catch (IOException e) { + log.error("failed to read " + file, e); + } finally { + if (rw != null) { + rw.release(); + } + } + return null; + } + + /** + * Writes a file to the tickets branch. + * + * @param db + * @param file + * @param content + * @param createdBy + * @param msg + */ + private void writeTicketsFile(Repository db, String file, String content, String createdBy, String msg) { + if (getTicketsBranch(db) == null) { + createTicketsBranch(db); + } + + DirCache newIndex = DirCache.newInCore(); + DirCacheBuilder builder = newIndex.builder(); + ObjectInserter inserter = db.newObjectInserter(); + + try { + // create an index entry for the revised index + final DirCacheEntry idIndexEntry = new DirCacheEntry(file); + idIndexEntry.setLength(content.length()); + idIndexEntry.setLastModified(System.currentTimeMillis()); + idIndexEntry.setFileMode(FileMode.REGULAR_FILE); + + // insert new ticket index + idIndexEntry.setObjectId(inserter.insert(org.eclipse.jgit.lib.Constants.OBJ_BLOB, + content.getBytes(Constants.ENCODING))); + + // add to temporary in-core index + builder.add(idIndexEntry); + + Set<String> ignorePaths = new HashSet<String>(); + ignorePaths.add(file); + + for (DirCacheEntry entry : getTreeEntries(db, ignorePaths)) { + builder.add(entry); + } + + // finish temporary in-core index used for this commit + builder.finish(); + + // commit the change + commitIndex(db, newIndex, createdBy, msg); + + } catch (ConcurrentRefUpdateException e) { + log.error("", e); + } catch (IOException e) { + log.error("", e); + } finally { + inserter.release(); + } + } + + /** + * Ensures that we have a ticket for this ticket id. + * + * @param repository + * @param ticketId + * @return true if the ticket exists + */ + @Override + public boolean hasTicket(RepositoryModel repository, long ticketId) { + boolean hasTicket = false; + Repository db = repositoryManager.getRepository(repository.name); + try { + RefModel ticketsBranch = getTicketsBranch(db); + if (ticketsBranch == null) { + return false; + } + String ticketPath = toTicketPath(ticketId); + RevCommit tip = JGitUtils.getCommit(db, BRANCH); + hasTicket = !JGitUtils.getFilesInPath(db, ticketPath, tip).isEmpty(); + } finally { + db.close(); + } + return hasTicket; + } + + /** + * Assigns a new ticket id. + * + * @param repository + * @return a new long id + */ + @Override + public synchronized long assignNewId(RepositoryModel repository) { + long newId = 0L; + Repository db = repositoryManager.getRepository(repository.name); + try { + if (getTicketsBranch(db) == null) { + createTicketsBranch(db); + } + + // identify current highest ticket id by scanning the paths in the tip tree + if (!lastAssignedId.containsKey(repository.name)) { + lastAssignedId.put(repository.name, new AtomicLong(0)); + } + AtomicLong lastId = lastAssignedId.get(repository.name); + if (lastId.get() <= 0) { + List<PathModel> paths = JGitUtils.getDocuments(db, Arrays.asList("json"), BRANCH); + for (PathModel path : paths) { + String name = path.name.substring(path.name.lastIndexOf('/') + 1); + if (!JOURNAL.equals(name)) { + continue; + } + String tid = path.path.split("/")[2]; + long ticketId = Long.parseLong(tid); + if (ticketId > lastId.get()) { + lastId.set(ticketId); + } + } + } + + // assign the id and touch an empty journal to hold it's place + newId = lastId.incrementAndGet(); + String journalPath = toTicketPath(newId) + "/" + JOURNAL; + writeTicketsFile(db, journalPath, "", "gitblit", "assigned id #" + newId); + } finally { + db.close(); + } + return newId; + } + + /** + * Returns all the tickets in the repository. Querying tickets from the + * repository requires deserializing all tickets. This is an expensive + * process and not recommended. Tickets are indexed by Lucene and queries + * should be executed against that index. + * + * @param repository + * @param filter + * optional filter to only return matching results + * @return a list of tickets + */ + @Override + public List<TicketModel> getTickets(RepositoryModel repository, TicketFilter filter) { + List<TicketModel> list = new ArrayList<TicketModel>(); + + Repository db = repositoryManager.getRepository(repository.name); + try { + RefModel ticketsBranch = getTicketsBranch(db); + if (ticketsBranch == null) { + return list; + } + + // Collect the set of all json files + List<PathModel> paths = JGitUtils.getDocuments(db, Arrays.asList("json"), BRANCH); + + // Deserialize each ticket and optionally filter out unwanted tickets + for (PathModel path : paths) { + String name = path.name.substring(path.name.lastIndexOf('/') + 1); + if (!JOURNAL.equals(name)) { + continue; + } + String json = readTicketsFile(db, path.path); + if (StringUtils.isEmpty(json)) { + // journal was touched but no changes were written + continue; + } + try { + // Reconstruct ticketId from the path + // id/26/326/journal.json + String tid = path.path.split("/")[2]; + long ticketId = Long.parseLong(tid); + List<Change> changes = TicketSerializer.deserializeJournal(json); + if (ArrayUtils.isEmpty(changes)) { + log.warn("Empty journal for {}:{}", repository, path.path); + continue; + } + TicketModel ticket = TicketModel.buildTicket(changes); + ticket.project = repository.projectPath; + ticket.repository = repository.name; + ticket.number = ticketId; + + // add the ticket, conditionally, to the list + if (filter == null) { + list.add(ticket); + } else { + if (filter.accept(ticket)) { + list.add(ticket); + } + } + } catch (Exception e) { + log.error("failed to deserialize {}/{}\n{}", + new Object [] { repository, path.path, e.getMessage()}); + log.error(null, e); + } + } + + // sort the tickets by creation + Collections.sort(list); + return list; + } finally { + db.close(); + } + } + + /** + * Retrieves the ticket from the repository by first looking-up the changeId + * associated with the ticketId. + * + * @param repository + * @param ticketId + * @return a ticket, if it exists, otherwise null + */ + @Override + protected TicketModel getTicketImpl(RepositoryModel repository, long ticketId) { + Repository db = repositoryManager.getRepository(repository.name); + try { + List<Change> changes = getJournal(db, ticketId); + if (ArrayUtils.isEmpty(changes)) { + log.warn("Empty journal for {}:{}", repository, ticketId); + return null; + } + TicketModel ticket = TicketModel.buildTicket(changes); + if (ticket != null) { + ticket.project = repository.projectPath; + ticket.repository = repository.name; + ticket.number = ticketId; + } + return ticket; + } finally { + db.close(); + } + } + + /** + * Returns the journal for the specified ticket. + * + * @param db + * @param ticketId + * @return a list of changes + */ + private List<Change> getJournal(Repository db, long ticketId) { + RefModel ticketsBranch = getTicketsBranch(db); + if (ticketsBranch == null) { + return new ArrayList<Change>(); + } + + if (ticketId <= 0L) { + return new ArrayList<Change>(); + } + + String journalPath = toTicketPath(ticketId) + "/" + JOURNAL; + String json = readTicketsFile(db, journalPath); + if (StringUtils.isEmpty(json)) { + return new ArrayList<Change>(); + } + List<Change> list = TicketSerializer.deserializeJournal(json); + return list; + } + + @Override + public boolean supportsAttachments() { + return true; + } + + /** + * Retrieves the specified attachment from a ticket. + * + * @param repository + * @param ticketId + * @param filename + * @return an attachment, if found, null otherwise + */ + @Override + public Attachment getAttachment(RepositoryModel repository, long ticketId, String filename) { + if (ticketId <= 0L) { + return null; + } + + // deserialize the ticket model so that we have the attachment metadata + TicketModel ticket = getTicket(repository, ticketId); + Attachment attachment = ticket.getAttachment(filename); + + // attachment not found + if (attachment == null) { + return null; + } + + // retrieve the attachment content + Repository db = repositoryManager.getRepository(repository.name); + try { + String attachmentPath = toAttachmentPath(ticketId, attachment.name); + RevTree tree = JGitUtils.getCommit(db, BRANCH).getTree(); + byte[] content = JGitUtils.getByteContent(db, tree, attachmentPath, false); + attachment.content = content; + attachment.size = content.length; + return attachment; + } finally { + db.close(); + } + } + + /** + * Deletes a ticket from the repository. + * + * @param ticket + * @return true if successful + */ + @Override + protected synchronized boolean deleteTicketImpl(RepositoryModel repository, TicketModel ticket, String deletedBy) { + if (ticket == null) { + throw new RuntimeException("must specify a ticket!"); + } + + boolean success = false; + Repository db = repositoryManager.getRepository(ticket.repository); + try { + RefModel ticketsBranch = getTicketsBranch(db); + + if (ticketsBranch == null) { + throw new RuntimeException(BRANCH + " does not exist!"); + } + String ticketPath = toTicketPath(ticket.number); + + TreeWalk treeWalk = null; + try { + ObjectId treeId = db.resolve(BRANCH + "^{tree}"); + + // Create the in-memory index of the new/updated ticket + DirCache index = DirCache.newInCore(); + DirCacheBuilder builder = index.builder(); + + // Traverse HEAD to add all other paths + treeWalk = new TreeWalk(db); + int hIdx = -1; + if (treeId != null) { + hIdx = treeWalk.addTree(treeId); + } + treeWalk.setRecursive(true); + while (treeWalk.next()) { + String path = treeWalk.getPathString(); + CanonicalTreeParser hTree = null; + if (hIdx != -1) { + hTree = treeWalk.getTree(hIdx, CanonicalTreeParser.class); + } + if (!path.startsWith(ticketPath)) { + // add entries from HEAD for all other paths + if (hTree != null) { + final DirCacheEntry entry = new DirCacheEntry(path); + entry.setObjectId(hTree.getEntryObjectId()); + entry.setFileMode(hTree.getEntryFileMode()); + + // add to temporary in-core index + builder.add(entry); + } + } + } + + // finish temporary in-core index used for this commit + builder.finish(); + + success = commitIndex(db, index, deletedBy, "- " + ticket.number); + + } catch (Throwable t) { + log.error(MessageFormat.format("Failed to delete ticket {0,number,0} from {1}", + ticket.number, db.getDirectory()), t); + } finally { + // release the treewalk + treeWalk.release(); + } + } finally { + db.close(); + } + return success; + } + + /** + * Commit a ticket change to the repository. + * + * @param repository + * @param ticketId + * @param change + * @return true, if the change was committed + */ + @Override + protected synchronized boolean commitChangeImpl(RepositoryModel repository, long ticketId, Change change) { + boolean success = false; + + Repository db = repositoryManager.getRepository(repository.name); + try { + DirCache index = createIndex(db, ticketId, change); + success = commitIndex(db, index, change.author, "#" + ticketId); + + } catch (Throwable t) { + log.error(MessageFormat.format("Failed to commit ticket {0,number,0} to {1}", + ticketId, db.getDirectory()), t); + } finally { + db.close(); + } + return success; + } + + /** + * Creates an in-memory index of the ticket change. + * + * @param changeId + * @param change + * @return an in-memory index + * @throws IOException + */ + private DirCache createIndex(Repository db, long ticketId, Change change) + throws IOException, ClassNotFoundException, NoSuchFieldException { + + String ticketPath = toTicketPath(ticketId); + DirCache newIndex = DirCache.newInCore(); + DirCacheBuilder builder = newIndex.builder(); + ObjectInserter inserter = db.newObjectInserter(); + + Set<String> ignorePaths = new TreeSet<String>(); + try { + // create/update the journal + // exclude the attachment content + List<Change> changes = getJournal(db, ticketId); + changes.add(change); + String journal = TicketSerializer.serializeJournal(changes).trim(); + + byte [] journalBytes = journal.getBytes(Constants.ENCODING); + String journalPath = ticketPath + "/" + JOURNAL; + final DirCacheEntry journalEntry = new DirCacheEntry(journalPath); + journalEntry.setLength(journalBytes.length); + journalEntry.setLastModified(change.date.getTime()); + journalEntry.setFileMode(FileMode.REGULAR_FILE); + journalEntry.setObjectId(inserter.insert(org.eclipse.jgit.lib.Constants.OBJ_BLOB, journalBytes)); + + // add journal to index + builder.add(journalEntry); + ignorePaths.add(journalEntry.getPathString()); + + // Add any attachments to the index + if (change.hasAttachments()) { + for (Attachment attachment : change.attachments) { + // build a path name for the attachment and mark as ignored + String path = toAttachmentPath(ticketId, attachment.name); + ignorePaths.add(path); + + // create an index entry for this attachment + final DirCacheEntry entry = new DirCacheEntry(path); + entry.setLength(attachment.content.length); + entry.setLastModified(change.date.getTime()); + entry.setFileMode(FileMode.REGULAR_FILE); + + // insert object + entry.setObjectId(inserter.insert(org.eclipse.jgit.lib.Constants.OBJ_BLOB, attachment.content)); + + // add to temporary in-core index + builder.add(entry); + } + } + + for (DirCacheEntry entry : getTreeEntries(db, ignorePaths)) { + builder.add(entry); + } + + // finish the index + builder.finish(); + } finally { + inserter.release(); + } + return newIndex; + } + + /** + * Returns all tree entries that do not match the ignore paths. + * + * @param db + * @param ignorePaths + * @param dcBuilder + * @throws IOException + */ + private List<DirCacheEntry> getTreeEntries(Repository db, Collection<String> ignorePaths) throws IOException { + List<DirCacheEntry> list = new ArrayList<DirCacheEntry>(); + TreeWalk tw = null; + try { + tw = new TreeWalk(db); + ObjectId treeId = db.resolve(BRANCH + "^{tree}"); + int hIdx = tw.addTree(treeId); + tw.setRecursive(true); + + while (tw.next()) { + String path = tw.getPathString(); + CanonicalTreeParser hTree = null; + if (hIdx != -1) { + hTree = tw.getTree(hIdx, CanonicalTreeParser.class); + } + if (!ignorePaths.contains(path)) { + // add all other tree entries + if (hTree != null) { + final DirCacheEntry entry = new DirCacheEntry(path); + entry.setObjectId(hTree.getEntryObjectId()); + entry.setFileMode(hTree.getEntryFileMode()); + list.add(entry); + } + } + } + } finally { + if (tw != null) { + tw.release(); + } + } + return list; + } + + private boolean commitIndex(Repository db, DirCache index, String author, String message) throws IOException, ConcurrentRefUpdateException { + boolean success = false; + + ObjectId headId = db.resolve(BRANCH + "^{commit}"); + if (headId == null) { + // create the branch + createTicketsBranch(db); + headId = db.resolve(BRANCH + "^{commit}"); + } + ObjectInserter odi = db.newObjectInserter(); + try { + // Create the in-memory index of the new/updated ticket + ObjectId indexTreeId = index.writeTree(odi); + + // Create a commit object + PersonIdent ident = new PersonIdent(author, "gitblit@localhost"); + CommitBuilder commit = new CommitBuilder(); + commit.setAuthor(ident); + commit.setCommitter(ident); + commit.setEncoding(Constants.ENCODING); + commit.setMessage(message); + commit.setParentId(headId); + commit.setTreeId(indexTreeId); + + // Insert the commit into the repository + ObjectId commitId = odi.insert(commit); + odi.flush(); + + RevWalk revWalk = new RevWalk(db); + try { + RevCommit revCommit = revWalk.parseCommit(commitId); + RefUpdate ru = db.updateRef(BRANCH); + ru.setNewObjectId(commitId); + ru.setExpectedOldObjectId(headId); + ru.setRefLogMessage("commit: " + revCommit.getShortMessage(), false); + Result rc = ru.forceUpdate(); + switch (rc) { + case NEW: + case FORCED: + case FAST_FORWARD: + success = true; + break; + case REJECTED: + case LOCK_FAILURE: + throw new ConcurrentRefUpdateException(JGitText.get().couldNotLockHEAD, + ru.getRef(), rc); + default: + throw new JGitInternalException(MessageFormat.format( + JGitText.get().updatingRefFailed, BRANCH, commitId.toString(), + rc)); + } + } finally { + revWalk.release(); + } + } finally { + odi.release(); + } + return success; + } + + @Override + protected boolean deleteAllImpl(RepositoryModel repository) { + Repository db = repositoryManager.getRepository(repository.name); + try { + RefModel branch = getTicketsBranch(db); + if (branch != null) { + return JGitUtils.deleteBranchRef(db, BRANCH); + } + return true; + } catch (Exception e) { + log.error(null, e); + } finally { + db.close(); + } + return false; + } + + @Override + protected boolean renameImpl(RepositoryModel oldRepository, RepositoryModel newRepository) { + return true; + } + + @Override + public String toString() { + return getClass().getSimpleName(); + } +} diff --git a/src/main/java/com/gitblit/tickets/FileTicketService.java b/src/main/java/com/gitblit/tickets/FileTicketService.java new file mode 100644 index 00000000..8375a2ba --- /dev/null +++ b/src/main/java/com/gitblit/tickets/FileTicketService.java @@ -0,0 +1,467 @@ +/* + * Copyright 2014 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.tickets; + +import java.io.File; +import java.io.IOException; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +import javax.inject.Inject; + +import org.eclipse.jgit.lib.Repository; + +import com.gitblit.Constants; +import com.gitblit.manager.INotificationManager; +import com.gitblit.manager.IRepositoryManager; +import com.gitblit.manager.IRuntimeManager; +import com.gitblit.manager.IUserManager; +import com.gitblit.models.RepositoryModel; +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Attachment; +import com.gitblit.models.TicketModel.Change; +import com.gitblit.utils.ArrayUtils; +import com.gitblit.utils.FileUtils; +import com.gitblit.utils.StringUtils; + +/** + * Implementation of a ticket service based on a directory within the repository. + * All tickets are serialized as a list of JSON changes and persisted in a hashed + * directory structure, similar to the standard git loose object structure. + * + * @author James Moger + * + */ +public class FileTicketService extends ITicketService { + + private static final String JOURNAL = "journal.json"; + + private static final String TICKETS_PATH = "tickets/"; + + private final Map<String, AtomicLong> lastAssignedId; + + @Inject + public FileTicketService( + IRuntimeManager runtimeManager, + INotificationManager notificationManager, + IUserManager userManager, + IRepositoryManager repositoryManager) { + + super(runtimeManager, + notificationManager, + userManager, + repositoryManager); + + lastAssignedId = new ConcurrentHashMap<String, AtomicLong>(); + } + + @Override + public FileTicketService start() { + return this; + } + + @Override + protected void resetCachesImpl() { + lastAssignedId.clear(); + } + + @Override + protected void resetCachesImpl(RepositoryModel repository) { + if (lastAssignedId.containsKey(repository.name)) { + lastAssignedId.get(repository.name).set(0); + } + } + + @Override + protected void close() { + } + + /** + * Returns the ticket path. This follows the same scheme as Git's object + * store path where the first two characters of the hash id are the root + * folder with the remaining characters as a subfolder within that folder. + * + * @param ticketId + * @return the root path of the ticket content in the ticket directory + */ + private String toTicketPath(long ticketId) { + StringBuilder sb = new StringBuilder(); + sb.append(TICKETS_PATH); + long m = ticketId % 100L; + if (m < 10) { + sb.append('0'); + } + sb.append(m); + sb.append('/'); + sb.append(ticketId); + return sb.toString(); + } + + /** + * Returns the path to the attachment for the specified ticket. + * + * @param ticketId + * @param filename + * @return the path to the specified attachment + */ + private String toAttachmentPath(long ticketId, String filename) { + return toTicketPath(ticketId) + "/attachments/" + filename; + } + + /** + * Ensures that we have a ticket for this ticket id. + * + * @param repository + * @param ticketId + * @return true if the ticket exists + */ + @Override + public boolean hasTicket(RepositoryModel repository, long ticketId) { + boolean hasTicket = false; + Repository db = repositoryManager.getRepository(repository.name); + try { + String journalPath = toTicketPath(ticketId) + "/" + JOURNAL; + hasTicket = new File(db.getDirectory(), journalPath).exists(); + } finally { + db.close(); + } + return hasTicket; + } + + /** + * Assigns a new ticket id. + * + * @param repository + * @return a new long id + */ + @Override + public synchronized long assignNewId(RepositoryModel repository) { + long newId = 0L; + Repository db = repositoryManager.getRepository(repository.name); + try { + if (!lastAssignedId.containsKey(repository.name)) { + lastAssignedId.put(repository.name, new AtomicLong(0)); + } + AtomicLong lastId = lastAssignedId.get(repository.name); + if (lastId.get() <= 0) { + // identify current highest ticket id by scanning the paths in the tip tree + File dir = new File(db.getDirectory(), TICKETS_PATH); + dir.mkdirs(); + List<File> journals = findAll(dir, JOURNAL); + for (File journal : journals) { + // Reconstruct ticketId from the path + // id/26/326/journal.json + String path = FileUtils.getRelativePath(dir, journal); + String tid = path.split("/")[1]; + long ticketId = Long.parseLong(tid); + if (ticketId > lastId.get()) { + lastId.set(ticketId); + } + } + } + + // assign the id and touch an empty journal to hold it's place + newId = lastId.incrementAndGet(); + String journalPath = toTicketPath(newId) + "/" + JOURNAL; + File journal = new File(db.getDirectory(), journalPath); + journal.getParentFile().mkdirs(); + journal.createNewFile(); + } catch (IOException e) { + log.error("failed to assign ticket id", e); + return 0L; + } finally { + db.close(); + } + return newId; + } + + /** + * Returns all the tickets in the repository. Querying tickets from the + * repository requires deserializing all tickets. This is an expensive + * process and not recommended. Tickets are indexed by Lucene and queries + * should be executed against that index. + * + * @param repository + * @param filter + * optional filter to only return matching results + * @return a list of tickets + */ + @Override + public List<TicketModel> getTickets(RepositoryModel repository, TicketFilter filter) { + List<TicketModel> list = new ArrayList<TicketModel>(); + + Repository db = repositoryManager.getRepository(repository.name); + try { + // Collect the set of all json files + File dir = new File(db.getDirectory(), TICKETS_PATH); + List<File> journals = findAll(dir, JOURNAL); + + // Deserialize each ticket and optionally filter out unwanted tickets + for (File journal : journals) { + String json = null; + try { + json = new String(FileUtils.readContent(journal), Constants.ENCODING); + } catch (Exception e) { + log.error(null, e); + } + if (StringUtils.isEmpty(json)) { + // journal was touched but no changes were written + continue; + } + try { + // Reconstruct ticketId from the path + // id/26/326/journal.json + String path = FileUtils.getRelativePath(dir, journal); + String tid = path.split("/")[1]; + long ticketId = Long.parseLong(tid); + List<Change> changes = TicketSerializer.deserializeJournal(json); + if (ArrayUtils.isEmpty(changes)) { + log.warn("Empty journal for {}:{}", repository, journal); + continue; + } + TicketModel ticket = TicketModel.buildTicket(changes); + ticket.project = repository.projectPath; + ticket.repository = repository.name; + ticket.number = ticketId; + + // add the ticket, conditionally, to the list + if (filter == null) { + list.add(ticket); + } else { + if (filter.accept(ticket)) { + list.add(ticket); + } + } + } catch (Exception e) { + log.error("failed to deserialize {}/{}\n{}", + new Object [] { repository, journal, e.getMessage()}); + log.error(null, e); + } + } + + // sort the tickets by creation + Collections.sort(list); + return list; + } finally { + db.close(); + } + } + + private List<File> findAll(File dir, String filename) { + List<File> list = new ArrayList<File>(); + for (File file : dir.listFiles()) { + if (file.isDirectory()) { + list.addAll(findAll(file, filename)); + } else if (file.isFile()) { + if (file.getName().equals(filename)) { + list.add(file); + } + } + } + return list; + } + + /** + * Retrieves the ticket from the repository by first looking-up the changeId + * associated with the ticketId. + * + * @param repository + * @param ticketId + * @return a ticket, if it exists, otherwise null + */ + @Override + protected TicketModel getTicketImpl(RepositoryModel repository, long ticketId) { + Repository db = repositoryManager.getRepository(repository.name); + try { + List<Change> changes = getJournal(db, ticketId); + if (ArrayUtils.isEmpty(changes)) { + log.warn("Empty journal for {}:{}", repository, ticketId); + return null; + } + TicketModel ticket = TicketModel.buildTicket(changes); + if (ticket != null) { + ticket.project = repository.projectPath; + ticket.repository = repository.name; + ticket.number = ticketId; + } + return ticket; + } finally { + db.close(); + } + } + + /** + * Returns the journal for the specified ticket. + * + * @param db + * @param ticketId + * @return a list of changes + */ + private List<Change> getJournal(Repository db, long ticketId) { + if (ticketId <= 0L) { + return new ArrayList<Change>(); + } + + String journalPath = toTicketPath(ticketId) + "/" + JOURNAL; + File journal = new File(db.getDirectory(), journalPath); + if (!journal.exists()) { + return new ArrayList<Change>(); + } + + String json = null; + try { + json = new String(FileUtils.readContent(journal), Constants.ENCODING); + } catch (Exception e) { + log.error(null, e); + } + if (StringUtils.isEmpty(json)) { + return new ArrayList<Change>(); + } + List<Change> list = TicketSerializer.deserializeJournal(json); + return list; + } + + @Override + public boolean supportsAttachments() { + return true; + } + + /** + * Retrieves the specified attachment from a ticket. + * + * @param repository + * @param ticketId + * @param filename + * @return an attachment, if found, null otherwise + */ + @Override + public Attachment getAttachment(RepositoryModel repository, long ticketId, String filename) { + if (ticketId <= 0L) { + return null; + } + + // deserialize the ticket model so that we have the attachment metadata + TicketModel ticket = getTicket(repository, ticketId); + Attachment attachment = ticket.getAttachment(filename); + + // attachment not found + if (attachment == null) { + return null; + } + + // retrieve the attachment content + Repository db = repositoryManager.getRepository(repository.name); + try { + String attachmentPath = toAttachmentPath(ticketId, attachment.name); + File file = new File(db.getDirectory(), attachmentPath); + if (file.exists()) { + attachment.content = FileUtils.readContent(file); + attachment.size = attachment.content.length; + } + return attachment; + } finally { + db.close(); + } + } + + /** + * Deletes a ticket from the repository. + * + * @param ticket + * @return true if successful + */ + @Override + protected synchronized boolean deleteTicketImpl(RepositoryModel repository, TicketModel ticket, String deletedBy) { + if (ticket == null) { + throw new RuntimeException("must specify a ticket!"); + } + + boolean success = false; + Repository db = repositoryManager.getRepository(ticket.repository); + try { + String ticketPath = toTicketPath(ticket.number); + File dir = new File(db.getDirectory(), ticketPath); + if (dir.exists()) { + success = FileUtils.delete(dir); + } + success = true; + } finally { + db.close(); + } + return success; + } + + /** + * Commit a ticket change to the repository. + * + * @param repository + * @param ticketId + * @param change + * @return true, if the change was committed + */ + @Override + protected synchronized boolean commitChangeImpl(RepositoryModel repository, long ticketId, Change change) { + boolean success = false; + + Repository db = repositoryManager.getRepository(repository.name); + try { + List<Change> changes = getJournal(db, ticketId); + changes.add(change); + String journal = TicketSerializer.serializeJournal(changes).trim(); + + String journalPath = toTicketPath(ticketId) + "/" + JOURNAL; + File file = new File(db.getDirectory(), journalPath); + file.getParentFile().mkdirs(); + FileUtils.writeContent(file, journal); + success = true; + } catch (Throwable t) { + log.error(MessageFormat.format("Failed to commit ticket {0,number,0} to {1}", + ticketId, db.getDirectory()), t); + } finally { + db.close(); + } + return success; + } + + @Override + protected boolean deleteAllImpl(RepositoryModel repository) { + Repository db = repositoryManager.getRepository(repository.name); + try { + File dir = new File(db.getDirectory(), TICKETS_PATH); + return FileUtils.delete(dir); + } catch (Exception e) { + log.error(null, e); + } finally { + db.close(); + } + return false; + } + + @Override + protected boolean renameImpl(RepositoryModel oldRepository, RepositoryModel newRepository) { + return true; + } + + @Override + public String toString() { + return getClass().getSimpleName(); + } +} diff --git a/src/main/java/com/gitblit/tickets/ITicketService.java b/src/main/java/com/gitblit/tickets/ITicketService.java new file mode 100644 index 00000000..d04cd5e1 --- /dev/null +++ b/src/main/java/com/gitblit/tickets/ITicketService.java @@ -0,0 +1,1088 @@ +/* + * Copyright 2013 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.tickets; + +import java.io.IOException; +import java.text.MessageFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.BitSet; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.StoredConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.gitblit.IStoredSettings; +import com.gitblit.Keys; +import com.gitblit.manager.INotificationManager; +import com.gitblit.manager.IRepositoryManager; +import com.gitblit.manager.IRuntimeManager; +import com.gitblit.manager.IUserManager; +import com.gitblit.models.RepositoryModel; +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Attachment; +import com.gitblit.models.TicketModel.Change; +import com.gitblit.models.TicketModel.Field; +import com.gitblit.models.TicketModel.Patchset; +import com.gitblit.models.TicketModel.Status; +import com.gitblit.tickets.TicketIndexer.Lucene; +import com.gitblit.utils.DiffUtils; +import com.gitblit.utils.DiffUtils.DiffStat; +import com.gitblit.utils.StringUtils; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; + +/** + * Abstract parent class of a ticket service that stubs out required methods + * and transparently handles Lucene indexing. + * + * @author James Moger + * + */ +public abstract class ITicketService { + + private static final String LABEL = "label"; + + private static final String MILESTONE = "milestone"; + + private static final String STATUS = "status"; + + private static final String COLOR = "color"; + + private static final String DUE = "due"; + + private static final String DUE_DATE_PATTERN = "yyyy-MM-dd"; + + /** + * Object filter interface to querying against all available ticket models. + */ + public interface TicketFilter { + + boolean accept(TicketModel ticket); + } + + protected final Logger log; + + protected final IStoredSettings settings; + + protected final IRuntimeManager runtimeManager; + + protected final INotificationManager notificationManager; + + protected final IUserManager userManager; + + protected final IRepositoryManager repositoryManager; + + protected final TicketIndexer indexer; + + private final Cache<TicketKey, TicketModel> ticketsCache; + + private final Map<String, List<TicketLabel>> labelsCache; + + private final Map<String, List<TicketMilestone>> milestonesCache; + + private static class TicketKey { + final String repository; + final long ticketId; + + TicketKey(RepositoryModel repository, long ticketId) { + this.repository = repository.name; + this.ticketId = ticketId; + } + + @Override + public int hashCode() { + return (repository + ticketId).hashCode(); + } + + @Override + public boolean equals(Object o) { + if (o instanceof TicketKey) { + return o.hashCode() == hashCode(); + } + return false; + } + + @Override + public String toString() { + return repository + ":" + ticketId; + } + } + + + /** + * Creates a ticket service. + */ + public ITicketService( + IRuntimeManager runtimeManager, + INotificationManager notificationManager, + IUserManager userManager, + IRepositoryManager repositoryManager) { + + this.log = LoggerFactory.getLogger(getClass()); + this.settings = runtimeManager.getSettings(); + this.runtimeManager = runtimeManager; + this.notificationManager = notificationManager; + this.userManager = userManager; + this.repositoryManager = repositoryManager; + + this.indexer = new TicketIndexer(runtimeManager); + + CacheBuilder<Object, Object> cb = CacheBuilder.newBuilder(); + this.ticketsCache = cb + .maximumSize(1000) + .expireAfterAccess(30, TimeUnit.MINUTES) + .build(); + + this.labelsCache = new ConcurrentHashMap<String, List<TicketLabel>>(); + this.milestonesCache = new ConcurrentHashMap<String, List<TicketMilestone>>(); + } + + /** + * Start the service. + * + */ + public abstract ITicketService start(); + + /** + * Stop the service. + * + */ + public final ITicketService stop() { + indexer.close(); + ticketsCache.invalidateAll(); + repositoryManager.closeAll(); + close(); + return this; + } + + /** + * Creates a ticket notifier. The ticket notifier is not thread-safe! + * + */ + public TicketNotifier createNotifier() { + return new TicketNotifier( + runtimeManager, + notificationManager, + userManager, + repositoryManager, + this); + } + + /** + * Returns the ready status of the ticket service. + * + * @return true if the ticket service is ready + */ + public boolean isReady() { + return true; + } + + /** + * Returns true if the new patchsets can be accepted for this repository. + * + * @param repository + * @return true if patchsets are being accepted + */ + public boolean isAcceptingNewPatchsets(RepositoryModel repository) { + return isReady() + && settings.getBoolean(Keys.tickets.acceptNewPatchsets, true) + && repository.acceptNewPatchsets + && isAcceptingTicketUpdates(repository); + } + + /** + * Returns true if new tickets can be manually created for this repository. + * This is separate from accepting patchsets. + * + * @param repository + * @return true if tickets are being accepted + */ + public boolean isAcceptingNewTickets(RepositoryModel repository) { + return isReady() + && settings.getBoolean(Keys.tickets.acceptNewTickets, true) + && repository.acceptNewTickets + && isAcceptingTicketUpdates(repository); + } + + /** + * Returns true if ticket updates are allowed for this repository. + * + * @param repository + * @return true if tickets are allowed to be updated + */ + public boolean isAcceptingTicketUpdates(RepositoryModel repository) { + return isReady() + && repository.isBare + && !repository.isFrozen + && !repository.isMirror; + } + + /** + * Returns true if the repository has any tickets + * @param repository + * @return true if the repository has tickets + */ + public boolean hasTickets(RepositoryModel repository) { + return indexer.hasTickets(repository); + } + + /** + * Closes any open resources used by this service. + */ + protected abstract void close(); + + /** + * Reset all caches in the service. + */ + public final synchronized void resetCaches() { + ticketsCache.invalidateAll(); + labelsCache.clear(); + milestonesCache.clear(); + resetCachesImpl(); + } + + protected abstract void resetCachesImpl(); + + /** + * Reset any caches for the repository in the service. + */ + public final synchronized void resetCaches(RepositoryModel repository) { + List<TicketKey> repoKeys = new ArrayList<TicketKey>(); + for (TicketKey key : ticketsCache.asMap().keySet()) { + if (key.repository.equals(repository.name)) { + repoKeys.add(key); + } + } + ticketsCache.invalidateAll(repoKeys); + labelsCache.remove(repository.name); + milestonesCache.remove(repository.name); + resetCachesImpl(repository); + } + + protected abstract void resetCachesImpl(RepositoryModel repository); + + + /** + * Returns the list of labels for the repository. + * + * @param repository + * @return the list of labels + */ + public List<TicketLabel> getLabels(RepositoryModel repository) { + String key = repository.name; + if (labelsCache.containsKey(key)) { + return labelsCache.get(key); + } + List<TicketLabel> list = new ArrayList<TicketLabel>(); + Repository db = repositoryManager.getRepository(repository.name); + try { + StoredConfig config = db.getConfig(); + Set<String> names = config.getSubsections(LABEL); + for (String name : names) { + TicketLabel label = new TicketLabel(name); + label.color = config.getString(LABEL, name, COLOR); + list.add(label); + } + labelsCache.put(key, Collections.unmodifiableList(list)); + } catch (Exception e) { + log.error("invalid tickets settings for " + repository, e); + } finally { + db.close(); + } + return list; + } + + /** + * Returns a TicketLabel object for a given label. If the label is not + * found, a ticket label object is created. + * + * @param repository + * @param label + * @return a TicketLabel + */ + public TicketLabel getLabel(RepositoryModel repository, String label) { + for (TicketLabel tl : getLabels(repository)) { + if (tl.name.equalsIgnoreCase(label)) { + String q = QueryBuilder.q(Lucene.rid.matches(repository.getRID())).and(Lucene.labels.matches(label)).build(); + tl.tickets = indexer.queryFor(q, 1, 0, Lucene.number.name(), true); + return tl; + } + } + return new TicketLabel(label); + } + + /** + * Creates a label. + * + * @param repository + * @param milestone + * @param createdBy + * @return the label + */ + public synchronized TicketLabel createLabel(RepositoryModel repository, String label, String createdBy) { + TicketLabel lb = new TicketMilestone(label); + Repository db = null; + try { + db = repositoryManager.getRepository(repository.name); + StoredConfig config = db.getConfig(); + config.setString(LABEL, label, COLOR, lb.color); + config.save(); + } catch (IOException e) { + log.error("failed to create label " + label + " in " + repository, e); + } finally { + db.close(); + } + return lb; + } + + /** + * Updates a label. + * + * @param repository + * @param label + * @param createdBy + * @return true if the update was successful + */ + public synchronized boolean updateLabel(RepositoryModel repository, TicketLabel label, String createdBy) { + Repository db = null; + try { + db = repositoryManager.getRepository(repository.name); + StoredConfig config = db.getConfig(); + config.setString(LABEL, label.name, COLOR, label.color); + config.save(); + + return true; + } catch (IOException e) { + log.error("failed to update label " + label + " in " + repository, e); + } finally { + db.close(); + } + return false; + } + + /** + * Renames a label. + * + * @param repository + * @param oldName + * @param newName + * @param createdBy + * @return true if the rename was successful + */ + public synchronized boolean renameLabel(RepositoryModel repository, String oldName, String newName, String createdBy) { + if (StringUtils.isEmpty(newName)) { + throw new IllegalArgumentException("new label can not be empty!"); + } + Repository db = null; + try { + db = repositoryManager.getRepository(repository.name); + TicketLabel label = getLabel(repository, oldName); + StoredConfig config = db.getConfig(); + config.unsetSection(LABEL, oldName); + config.setString(LABEL, newName, COLOR, label.color); + config.save(); + + for (QueryResult qr : label.tickets) { + Change change = new Change(createdBy); + change.unlabel(oldName); + change.label(newName); + updateTicket(repository, qr.number, change); + } + + return true; + } catch (IOException e) { + log.error("failed to rename label " + oldName + " in " + repository, e); + } finally { + db.close(); + } + return false; + } + + /** + * Deletes a label. + * + * @param repository + * @param label + * @param createdBy + * @return true if the delete was successful + */ + public synchronized boolean deleteLabel(RepositoryModel repository, String label, String createdBy) { + if (StringUtils.isEmpty(label)) { + throw new IllegalArgumentException("label can not be empty!"); + } + Repository db = null; + try { + db = repositoryManager.getRepository(repository.name); + StoredConfig config = db.getConfig(); + config.unsetSection(LABEL, label); + config.save(); + + return true; + } catch (IOException e) { + log.error("failed to delete label " + label + " in " + repository, e); + } finally { + db.close(); + } + return false; + } + + /** + * Returns the list of milestones for the repository. + * + * @param repository + * @return the list of milestones + */ + public List<TicketMilestone> getMilestones(RepositoryModel repository) { + String key = repository.name; + if (milestonesCache.containsKey(key)) { + return milestonesCache.get(key); + } + List<TicketMilestone> list = new ArrayList<TicketMilestone>(); + Repository db = repositoryManager.getRepository(repository.name); + try { + StoredConfig config = db.getConfig(); + Set<String> names = config.getSubsections(MILESTONE); + for (String name : names) { + TicketMilestone milestone = new TicketMilestone(name); + milestone.status = Status.fromObject(config.getString(MILESTONE, name, STATUS), milestone.status); + milestone.color = config.getString(MILESTONE, name, COLOR); + String due = config.getString(MILESTONE, name, DUE); + if (!StringUtils.isEmpty(due)) { + try { + milestone.due = new SimpleDateFormat(DUE_DATE_PATTERN).parse(due); + } catch (ParseException e) { + log.error("failed to parse {} milestone {} due date \"{}\"", + new Object [] { repository, name, due }); + } + } + list.add(milestone); + } + milestonesCache.put(key, Collections.unmodifiableList(list)); + } catch (Exception e) { + log.error("invalid tickets settings for " + repository, e); + } finally { + db.close(); + } + return list; + } + + /** + * Returns the list of milestones for the repository that match the status. + * + * @param repository + * @param status + * @return the list of milestones + */ + public List<TicketMilestone> getMilestones(RepositoryModel repository, Status status) { + List<TicketMilestone> matches = new ArrayList<TicketMilestone>(); + for (TicketMilestone milestone : getMilestones(repository)) { + if (status == milestone.status) { + matches.add(milestone); + } + } + return matches; + } + + /** + * Returns the specified milestone or null if the milestone does not exist. + * + * @param repository + * @param milestone + * @return the milestone or null if it does not exist + */ + public TicketMilestone getMilestone(RepositoryModel repository, String milestone) { + for (TicketMilestone ms : getMilestones(repository)) { + if (ms.name.equalsIgnoreCase(milestone)) { + String q = QueryBuilder.q(Lucene.rid.matches(repository.getRID())).and(Lucene.milestone.matches(milestone)).build(); + ms.tickets = indexer.queryFor(q, 1, 0, Lucene.number.name(), true); + return ms; + } + } + return null; + } + + /** + * Creates a milestone. + * + * @param repository + * @param milestone + * @param createdBy + * @return the milestone + */ + public synchronized TicketMilestone createMilestone(RepositoryModel repository, String milestone, String createdBy) { + TicketMilestone ms = new TicketMilestone(milestone); + Repository db = null; + try { + db = repositoryManager.getRepository(repository.name); + StoredConfig config = db.getConfig(); + config.setString(MILESTONE, milestone, STATUS, ms.status.name()); + config.setString(MILESTONE, milestone, COLOR, ms.color); + config.save(); + + milestonesCache.remove(repository.name); + } catch (IOException e) { + log.error("failed to create milestone " + milestone + " in " + repository, e); + } finally { + db.close(); + } + return ms; + } + + /** + * Updates a milestone. + * + * @param repository + * @param milestone + * @param createdBy + * @return true if successful + */ + public synchronized boolean updateMilestone(RepositoryModel repository, TicketMilestone milestone, String createdBy) { + Repository db = null; + try { + db = repositoryManager.getRepository(repository.name); + StoredConfig config = db.getConfig(); + config.setString(MILESTONE, milestone.name, STATUS, milestone.status.name()); + config.setString(MILESTONE, milestone.name, COLOR, milestone.color); + if (milestone.due != null) { + config.setString(MILESTONE, milestone.name, DUE, + new SimpleDateFormat(DUE_DATE_PATTERN).format(milestone.due)); + } + config.save(); + + milestonesCache.remove(repository.name); + return true; + } catch (IOException e) { + log.error("failed to update milestone " + milestone + " in " + repository, e); + } finally { + db.close(); + } + return false; + } + + /** + * Renames a milestone. + * + * @param repository + * @param oldName + * @param newName + * @param createdBy + * @return true if successful + */ + public synchronized boolean renameMilestone(RepositoryModel repository, String oldName, String newName, String createdBy) { + if (StringUtils.isEmpty(newName)) { + throw new IllegalArgumentException("new milestone can not be empty!"); + } + Repository db = null; + try { + db = repositoryManager.getRepository(repository.name); + TicketMilestone milestone = getMilestone(repository, oldName); + StoredConfig config = db.getConfig(); + config.unsetSection(MILESTONE, oldName); + config.setString(MILESTONE, newName, STATUS, milestone.status.name()); + config.setString(MILESTONE, newName, COLOR, milestone.color); + if (milestone.due != null) { + config.setString(MILESTONE, milestone.name, DUE, + new SimpleDateFormat(DUE_DATE_PATTERN).format(milestone.due)); + } + config.save(); + + milestonesCache.remove(repository.name); + + TicketNotifier notifier = createNotifier(); + for (QueryResult qr : milestone.tickets) { + Change change = new Change(createdBy); + change.setField(Field.milestone, newName); + TicketModel ticket = updateTicket(repository, qr.number, change); + notifier.queueMailing(ticket); + } + notifier.sendAll(); + + return true; + } catch (IOException e) { + log.error("failed to rename milestone " + oldName + " in " + repository, e); + } finally { + db.close(); + } + return false; + } + /** + * Deletes a milestone. + * + * @param repository + * @param milestone + * @param createdBy + * @return true if successful + */ + public synchronized boolean deleteMilestone(RepositoryModel repository, String milestone, String createdBy) { + if (StringUtils.isEmpty(milestone)) { + throw new IllegalArgumentException("milestone can not be empty!"); + } + Repository db = null; + try { + db = repositoryManager.getRepository(repository.name); + StoredConfig config = db.getConfig(); + config.unsetSection(MILESTONE, milestone); + config.save(); + + milestonesCache.remove(repository.name); + + return true; + } catch (IOException e) { + log.error("failed to delete milestone " + milestone + " in " + repository, e); + } finally { + db.close(); + } + return false; + } + + /** + * Assigns a new ticket id. + * + * @param repository + * @return a new ticket id + */ + public abstract long assignNewId(RepositoryModel repository); + + /** + * Ensures that we have a ticket for this ticket id. + * + * @param repository + * @param ticketId + * @return true if the ticket exists + */ + public abstract boolean hasTicket(RepositoryModel repository, long ticketId); + + /** + * Returns all tickets. This is not a Lucene search! + * + * @param repository + * @return all tickets + */ + public List<TicketModel> getTickets(RepositoryModel repository) { + return getTickets(repository, null); + } + + /** + * Returns all tickets that satisfy the filter. Retrieving tickets from the + * service requires deserializing all journals and building ticket models. + * This is an expensive process and not recommended. Instead, the queryFor + * method should be used which executes against the Lucene index. + * + * @param repository + * @param filter + * optional issue filter to only return matching results + * @return a list of tickets + */ + public abstract List<TicketModel> getTickets(RepositoryModel repository, TicketFilter filter); + + /** + * Retrieves the ticket. + * + * @param repository + * @param ticketId + * @return a ticket, if it exists, otherwise null + */ + public final TicketModel getTicket(RepositoryModel repository, long ticketId) { + TicketKey key = new TicketKey(repository, ticketId); + TicketModel ticket = ticketsCache.getIfPresent(key); + + if (ticket == null) { + // load & cache ticket + ticket = getTicketImpl(repository, ticketId); + if (ticket.hasPatchsets()) { + Repository r = repositoryManager.getRepository(repository.name); + try { + Patchset patchset = ticket.getCurrentPatchset(); + DiffStat diffStat = DiffUtils.getDiffStat(r, patchset.base, patchset.tip); + // diffstat could be null if we have ticket data without the + // commit objects. e.g. ticket replication without repo + // mirroring + if (diffStat != null) { + ticket.insertions = diffStat.getInsertions(); + ticket.deletions = diffStat.getDeletions(); + } + } finally { + r.close(); + } + } + if (ticket != null) { + ticketsCache.put(key, ticket); + } + } + return ticket; + } + + /** + * Retrieves the ticket. + * + * @param repository + * @param ticketId + * @return a ticket, if it exists, otherwise null + */ + protected abstract TicketModel getTicketImpl(RepositoryModel repository, long ticketId); + + /** + * Get the ticket url + * + * @param ticket + * @return the ticket url + */ + public String getTicketUrl(TicketModel ticket) { + final String canonicalUrl = settings.getString(Keys.web.canonicalUrl, "https://localhost:8443"); + final String hrefPattern = "{0}/tickets?r={1}&h={2,number,0}"; + return MessageFormat.format(hrefPattern, canonicalUrl, ticket.repository, ticket.number); + } + + /** + * Get the compare url + * + * @param base + * @param tip + * @return the compare url + */ + public String getCompareUrl(TicketModel ticket, String base, String tip) { + final String canonicalUrl = settings.getString(Keys.web.canonicalUrl, "https://localhost:8443"); + final String hrefPattern = "{0}/compare?r={1}&h={2}..{3}"; + return MessageFormat.format(hrefPattern, canonicalUrl, ticket.repository, base, tip); + } + + /** + * Returns true if attachments are supported. + * + * @return true if attachments are supported + */ + public abstract boolean supportsAttachments(); + + /** + * Retrieves the specified attachment from a ticket. + * + * @param repository + * @param ticketId + * @param filename + * @return an attachment, if found, null otherwise + */ + public abstract Attachment getAttachment(RepositoryModel repository, long ticketId, String filename); + + /** + * Creates a ticket. Your change must include a repository, author & title, + * at a minimum. If your change does not have those minimum requirements a + * RuntimeException will be thrown. + * + * @param repository + * @param change + * @return true if successful + */ + public TicketModel createTicket(RepositoryModel repository, Change change) { + return createTicket(repository, 0L, change); + } + + /** + * Creates a ticket. Your change must include a repository, author & title, + * at a minimum. If your change does not have those minimum requirements a + * RuntimeException will be thrown. + * + * @param repository + * @param ticketId (if <=0 the ticket id will be assigned) + * @param change + * @return true if successful + */ + public TicketModel createTicket(RepositoryModel repository, long ticketId, Change change) { + + if (repository == null) { + throw new RuntimeException("Must specify a repository!"); + } + if (StringUtils.isEmpty(change.author)) { + throw new RuntimeException("Must specify a change author!"); + } + if (!change.hasField(Field.title)) { + throw new RuntimeException("Must specify a title!"); + } + + change.watch(change.author); + + if (ticketId <= 0L) { + ticketId = assignNewId(repository); + } + + change.setField(Field.status, Status.New); + + boolean success = commitChangeImpl(repository, ticketId, change); + if (success) { + TicketModel ticket = getTicket(repository, ticketId); + indexer.index(ticket); + return ticket; + } + return null; + } + + /** + * Updates a ticket. + * + * @param repository + * @param ticketId + * @param change + * @return the ticket model if successful + */ + public final TicketModel updateTicket(RepositoryModel repository, long ticketId, Change change) { + if (change == null) { + throw new RuntimeException("change can not be null!"); + } + + if (StringUtils.isEmpty(change.author)) { + throw new RuntimeException("must specify a change author!"); + } + + TicketKey key = new TicketKey(repository, ticketId); + ticketsCache.invalidate(key); + + boolean success = commitChangeImpl(repository, ticketId, change); + if (success) { + TicketModel ticket = getTicket(repository, ticketId); + ticketsCache.put(key, ticket); + indexer.index(ticket); + return ticket; + } + return null; + } + + /** + * Deletes all tickets in every repository. + * + * @return true if successful + */ + public boolean deleteAll() { + List<String> repositories = repositoryManager.getRepositoryList(); + BitSet bitset = new BitSet(repositories.size()); + for (int i = 0; i < repositories.size(); i++) { + String name = repositories.get(i); + RepositoryModel repository = repositoryManager.getRepositoryModel(name); + boolean success = deleteAll(repository); + bitset.set(i, success); + } + boolean success = bitset.cardinality() == repositories.size(); + if (success) { + indexer.deleteAll(); + resetCaches(); + } + return success; + } + + /** + * Deletes all tickets in the specified repository. + * @param repository + * @return true if succesful + */ + public boolean deleteAll(RepositoryModel repository) { + boolean success = deleteAllImpl(repository); + if (success) { + resetCaches(repository); + indexer.deleteAll(repository); + } + return success; + } + + protected abstract boolean deleteAllImpl(RepositoryModel repository); + + /** + * Handles repository renames. + * + * @param oldRepositoryName + * @param newRepositoryName + * @return true if successful + */ + public boolean rename(RepositoryModel oldRepository, RepositoryModel newRepository) { + if (renameImpl(oldRepository, newRepository)) { + resetCaches(oldRepository); + indexer.deleteAll(oldRepository); + reindex(newRepository); + return true; + } + return false; + } + + protected abstract boolean renameImpl(RepositoryModel oldRepository, RepositoryModel newRepository); + + /** + * Deletes a ticket. + * + * @param repository + * @param ticketId + * @param deletedBy + * @return true if successful + */ + public boolean deleteTicket(RepositoryModel repository, long ticketId, String deletedBy) { + TicketModel ticket = getTicket(repository, ticketId); + boolean success = deleteTicketImpl(repository, ticket, deletedBy); + if (success) { + ticketsCache.invalidate(new TicketKey(repository, ticketId)); + indexer.delete(ticket); + return true; + } + return false; + } + + /** + * Deletes a ticket. + * + * @param repository + * @param ticket + * @param deletedBy + * @return true if successful + */ + protected abstract boolean deleteTicketImpl(RepositoryModel repository, TicketModel ticket, String deletedBy); + + + /** + * Updates the text of an ticket comment. + * + * @param ticket + * @param commentId + * the id of the comment to revise + * @param updatedBy + * the author of the updated comment + * @param comment + * the revised comment + * @return the revised ticket if the change was successful + */ + public final TicketModel updateComment(TicketModel ticket, String commentId, + String updatedBy, String comment) { + Change revision = new Change(updatedBy); + revision.comment(comment); + revision.comment.id = commentId; + RepositoryModel repository = repositoryManager.getRepositoryModel(ticket.repository); + TicketModel revisedTicket = updateTicket(repository, ticket.number, revision); + return revisedTicket; + } + + /** + * Deletes a comment from a ticket. + * + * @param ticket + * @param commentId + * the id of the comment to delete + * @param deletedBy + * the user deleting the comment + * @return the revised ticket if the deletion was successful + */ + public final TicketModel deleteComment(TicketModel ticket, String commentId, String deletedBy) { + Change deletion = new Change(deletedBy); + deletion.comment(""); + deletion.comment.id = commentId; + deletion.comment.deleted = true; + RepositoryModel repository = repositoryManager.getRepositoryModel(ticket.repository); + TicketModel revisedTicket = updateTicket(repository, ticket.number, deletion); + return revisedTicket; + } + + /** + * Commit a ticket change to the repository. + * + * @param repository + * @param ticketId + * @param change + * @return true, if the change was committed + */ + protected abstract boolean commitChangeImpl(RepositoryModel repository, long ticketId, Change change); + + + /** + * Searches for the specified text. This will use the indexer, if available, + * or will fall back to brute-force retrieval of all tickets and string + * matching. + * + * @param repository + * @param text + * @param page + * @param pageSize + * @return a list of matching tickets + */ + public List<QueryResult> searchFor(RepositoryModel repository, String text, int page, int pageSize) { + return indexer.searchFor(repository, text, page, pageSize); + } + + /** + * Queries the index for the matching tickets. + * + * @param query + * @param page + * @param pageSize + * @param sortBy + * @param descending + * @return a list of matching tickets or an empty list + */ + public List<QueryResult> queryFor(String query, int page, int pageSize, String sortBy, boolean descending) { + return indexer.queryFor(query, page, pageSize, sortBy, descending); + } + + /** + * Destroys an existing index and reindexes all tickets. + * This operation may be expensive and time-consuming. + */ + public void reindex() { + long start = System.nanoTime(); + indexer.deleteAll(); + for (String name : repositoryManager.getRepositoryList()) { + RepositoryModel repository = repositoryManager.getRepositoryModel(name); + try { + List<TicketModel> tickets = getTickets(repository); + if (!tickets.isEmpty()) { + log.info("reindexing {} tickets from {} ...", tickets.size(), repository); + indexer.index(tickets); + System.gc(); + } + } catch (Exception e) { + log.error("failed to reindex {}", repository.name); + log.error(null, e); + } + } + long end = System.nanoTime(); + long secs = TimeUnit.NANOSECONDS.toMillis(end - start); + log.info("reindexing completed in {} msecs.", secs); + } + + /** + * Destroys any existing index and reindexes all tickets. + * This operation may be expensive and time-consuming. + */ + public void reindex(RepositoryModel repository) { + long start = System.nanoTime(); + List<TicketModel> tickets = getTickets(repository); + indexer.index(tickets); + log.info("reindexing {} tickets from {} ...", tickets.size(), repository); + long end = System.nanoTime(); + long secs = TimeUnit.NANOSECONDS.toMillis(end - start); + log.info("reindexing completed in {} msecs.", secs); + } + + /** + * Synchronously executes the runnable. This is used for special processing + * of ticket updates, namely merging from the web ui. + * + * @param runnable + */ + public synchronized void exec(Runnable runnable) { + runnable.run(); + } +} diff --git a/src/main/java/com/gitblit/tickets/NullTicketService.java b/src/main/java/com/gitblit/tickets/NullTicketService.java new file mode 100644 index 00000000..cc893025 --- /dev/null +++ b/src/main/java/com/gitblit/tickets/NullTicketService.java @@ -0,0 +1,129 @@ +/* + * Copyright 2014 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.tickets; + +import java.util.Collections; +import java.util.List; + +import javax.inject.Inject; + +import com.gitblit.manager.INotificationManager; +import com.gitblit.manager.IRepositoryManager; +import com.gitblit.manager.IRuntimeManager; +import com.gitblit.manager.IUserManager; +import com.gitblit.models.RepositoryModel; +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Attachment; +import com.gitblit.models.TicketModel.Change; + +/** + * Implementation of a ticket service that rejects everything. + * + * @author James Moger + * + */ +public class NullTicketService extends ITicketService { + + @Inject + public NullTicketService( + IRuntimeManager runtimeManager, + INotificationManager notificationManager, + IUserManager userManager, + IRepositoryManager repositoryManager) { + + super(runtimeManager, + notificationManager, + userManager, + repositoryManager); + } + + @Override + public boolean isReady() { + return false; + } + + @Override + public NullTicketService start() { + return this; + } + + @Override + protected void resetCachesImpl() { + } + + @Override + protected void resetCachesImpl(RepositoryModel repository) { + } + + @Override + protected void close() { + } + + @Override + public boolean hasTicket(RepositoryModel repository, long ticketId) { + return false; + } + + @Override + public synchronized long assignNewId(RepositoryModel repository) { + return 0L; + } + + @Override + public List<TicketModel> getTickets(RepositoryModel repository, TicketFilter filter) { + return Collections.emptyList(); + } + + @Override + protected TicketModel getTicketImpl(RepositoryModel repository, long ticketId) { + return null; + } + + @Override + public boolean supportsAttachments() { + return false; + } + + @Override + public Attachment getAttachment(RepositoryModel repository, long ticketId, String filename) { + return null; + } + + @Override + protected synchronized boolean deleteTicketImpl(RepositoryModel repository, TicketModel ticket, String deletedBy) { + return false; + } + + @Override + protected synchronized boolean commitChangeImpl(RepositoryModel repository, long ticketId, Change change) { + return false; + } + + @Override + protected boolean deleteAllImpl(RepositoryModel repository) { + return false; + } + + @Override + protected boolean renameImpl(RepositoryModel oldRepository, RepositoryModel newRepository) { + return false; + } + + @Override + public String toString() { + return getClass().getSimpleName(); + } +} diff --git a/src/main/java/com/gitblit/tickets/QueryBuilder.java b/src/main/java/com/gitblit/tickets/QueryBuilder.java new file mode 100644 index 00000000..17aeb988 --- /dev/null +++ b/src/main/java/com/gitblit/tickets/QueryBuilder.java @@ -0,0 +1,222 @@ +/* + * Copyright 2013 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.tickets; + +import com.gitblit.utils.StringUtils; + +/** + * A Lucene query builder. + * + * @author James Moger + * + */ +public class QueryBuilder { + + private final QueryBuilder parent; + private String q; + private transient StringBuilder sb; + private int opCount; + + public static QueryBuilder q(String kernel) { + return new QueryBuilder(kernel); + } + + private QueryBuilder(QueryBuilder parent) { + this.sb = new StringBuilder(); + this.parent = parent; + } + + public QueryBuilder() { + this(""); + } + + public QueryBuilder(String query) { + this.sb = new StringBuilder(query == null ? "" : query); + this.parent = null; + } + + public boolean containsField(String field) { + return sb.toString().contains(field + ":"); + } + + /** + * Creates a new AND subquery. Make sure to call endSubquery to + * get return *this* query. + * + * e.g. field:something AND (subquery) + * + * @return a subquery + */ + public QueryBuilder andSubquery() { + sb.append(" AND ("); + return new QueryBuilder(this); + } + + /** + * Creates a new OR subquery. Make sure to call endSubquery to + * get return *this* query. + * + * e.g. field:something OR (subquery) + * + * @return a subquery + */ + public QueryBuilder orSubquery() { + sb.append(" OR ("); + return new QueryBuilder(this); + } + + /** + * Ends a subquery and returns the parent query. + * + * @return the parent query + */ + public QueryBuilder endSubquery() { + this.q = sb.toString().trim(); + if (q.length() > 0) { + parent.sb.append(q).append(')'); + } + return parent; + } + + /** + * Append an OR condition. + * + * @param condition + * @return + */ + public QueryBuilder or(String condition) { + return op(condition, " OR "); + } + + /** + * Append an AND condition. + * + * @param condition + * @return + */ + public QueryBuilder and(String condition) { + return op(condition, " AND "); + } + + /** + * Append an AND NOT condition. + * + * @param condition + * @return + */ + public QueryBuilder andNot(String condition) { + return op(condition, " AND NOT "); + } + + /** + * Nest this query as a subquery. + * + * e.g. field:something AND field2:something else + * ==> (field:something AND field2:something else) + * + * @return this query nested as a subquery + */ + public QueryBuilder toSubquery() { + if (opCount > 1) { + sb.insert(0, '(').append(')'); + } + return this; + } + + /** + * Nest this query as an AND subquery of the condition + * + * @param condition + * @return the query nested as an AND subquery of the specified condition + */ + public QueryBuilder subqueryOf(String condition) { + if (!StringUtils.isEmpty(condition)) { + toSubquery().and(condition); + } + return this; + } + + /** + * Removes a condition from the query. + * + * @param condition + * @return the query + */ + public QueryBuilder remove(String condition) { + int start = sb.indexOf(condition); + if (start == 0) { + // strip first condition + sb.replace(0, condition.length(), ""); + } else if (start > 1) { + // locate condition in query + int space1 = sb.lastIndexOf(" ", start - 1); + int space0 = sb.lastIndexOf(" ", space1 - 1); + if (space0 > -1 && space1 > -1) { + String conjunction = sb.substring(space0, space1).trim(); + if ("OR".equals(conjunction) || "AND".equals(conjunction)) { + // remove the conjunction + sb.replace(space0, start + condition.length(), ""); + } else { + // unknown conjunction + sb.replace(start, start + condition.length(), ""); + } + } else { + sb.replace(start, start + condition.length(), ""); + } + } + return this; + } + + /** + * Generate the return the Lucene query. + * + * @return the generated query + */ + public String build() { + if (parent != null) { + throw new IllegalAccessError("You can not build a subquery! endSubquery() instead!"); + } + this.q = sb.toString().trim(); + + // cleanup paranthesis + while (q.contains("()")) { + q = q.replace("()", ""); + } + if (q.length() > 0) { + if (q.charAt(0) == '(' && q.charAt(q.length() - 1) == ')') { + // query is wrapped by unnecessary paranthesis + q = q.substring(1, q.length() - 1); + } + } + return q; + } + + private QueryBuilder op(String condition, String op) { + opCount++; + if (!StringUtils.isEmpty(condition)) { + if (sb.length() != 0) { + sb.append(op); + } + sb.append(condition); + } + return this; + } + + @Override + public String toString() { + return sb.toString().trim(); + } +}
\ No newline at end of file diff --git a/src/main/java/com/gitblit/tickets/QueryResult.java b/src/main/java/com/gitblit/tickets/QueryResult.java new file mode 100644 index 00000000..9f5d3a55 --- /dev/null +++ b/src/main/java/com/gitblit/tickets/QueryResult.java @@ -0,0 +1,114 @@ +/* + * Copyright 2013 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.tickets; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +import com.gitblit.models.TicketModel.Patchset; +import com.gitblit.models.TicketModel.Status; +import com.gitblit.models.TicketModel.Type; +import com.gitblit.utils.StringUtils; + +/** + * Represents the results of a query to the ticket index. + * + * @author James Moger + * + */ +public class QueryResult implements Serializable { + + private static final long serialVersionUID = 1L; + + public String project; + public String repository; + public long number; + public String createdBy; + public Date createdAt; + public String updatedBy; + public Date updatedAt; + public String dependsOn; + public String title; + public String body; + public Status status; + public String responsible; + public String milestone; + public String topic; + public Type type; + public String mergeSha; + public String mergeTo; + public List<String> labels; + public List<String> attachments; + public List<String> participants; + public List<String> watchedby; + public List<String> mentions; + public Patchset patchset; + public int commentsCount; + public int votesCount; + public int approvalsCount; + + public int docId; + public int totalResults; + + public Date getDate() { + return updatedAt == null ? createdAt : updatedAt; + } + + public boolean isProposal() { + return type != null && Type.Proposal == type; + } + + public boolean isMerged() { + return Status.Merged == status && !StringUtils.isEmpty(mergeSha); + } + + public boolean isWatching(String username) { + return watchedby != null && watchedby.contains(username); + } + + public List<String> getLabels() { + List<String> list = new ArrayList<String>(); + if (labels != null) { + list.addAll(labels); + } + if (topic != null) { + list.add(topic); + } + Collections.sort(list); + return list; + } + + @Override + public boolean equals(Object o) { + if (o instanceof QueryResult) { + return hashCode() == o.hashCode(); + } + return false; + } + + @Override + public int hashCode() { + return (repository + number).hashCode(); + } + + @Override + public String toString() { + return repository + "-" + number; + } +} diff --git a/src/main/java/com/gitblit/tickets/RedisTicketService.java b/src/main/java/com/gitblit/tickets/RedisTicketService.java new file mode 100644 index 00000000..5653f698 --- /dev/null +++ b/src/main/java/com/gitblit/tickets/RedisTicketService.java @@ -0,0 +1,534 @@ +/* + * Copyright 2013 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.tickets; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import javax.inject.Inject; + +import org.apache.commons.pool2.impl.GenericObjectPoolConfig; + +import redis.clients.jedis.Client; +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPool; +import redis.clients.jedis.Protocol; +import redis.clients.jedis.Transaction; +import redis.clients.jedis.exceptions.JedisException; + +import com.gitblit.Keys; +import com.gitblit.manager.INotificationManager; +import com.gitblit.manager.IRepositoryManager; +import com.gitblit.manager.IRuntimeManager; +import com.gitblit.manager.IUserManager; +import com.gitblit.models.RepositoryModel; +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Attachment; +import com.gitblit.models.TicketModel.Change; +import com.gitblit.utils.ArrayUtils; +import com.gitblit.utils.StringUtils; + +/** + * Implementation of a ticket service based on a Redis key-value store. All + * tickets are persisted in the Redis store so it must be configured for + * durability otherwise tickets are lost on a flush or restart. Tickets are + * indexed with Lucene and all queries are executed against the Lucene index. + * + * @author James Moger + * + */ +public class RedisTicketService extends ITicketService { + + private final JedisPool pool; + + private enum KeyType { + journal, ticket, counter + } + + @Inject + public RedisTicketService( + IRuntimeManager runtimeManager, + INotificationManager notificationManager, + IUserManager userManager, + IRepositoryManager repositoryManager) { + + super(runtimeManager, + notificationManager, + userManager, + repositoryManager); + + String redisUrl = settings.getString(Keys.tickets.redis.url, ""); + this.pool = createPool(redisUrl); + } + + @Override + public RedisTicketService start() { + return this; + } + + @Override + protected void resetCachesImpl() { + } + + @Override + protected void resetCachesImpl(RepositoryModel repository) { + } + + @Override + protected void close() { + pool.destroy(); + } + + @Override + public boolean isReady() { + return pool != null; + } + + /** + * Constructs a key for use with a key-value data store. + * + * @param key + * @param repository + * @param id + * @return a key + */ + private String key(RepositoryModel repository, KeyType key, String id) { + StringBuilder sb = new StringBuilder(); + sb.append(repository.name).append(':'); + sb.append(key.name()); + if (!StringUtils.isEmpty(id)) { + sb.append(':'); + sb.append(id); + } + return sb.toString(); + } + + /** + * Constructs a key for use with a key-value data store. + * + * @param key + * @param repository + * @param id + * @return a key + */ + private String key(RepositoryModel repository, KeyType key, long id) { + return key(repository, key, "" + id); + } + + private boolean isNull(String value) { + return value == null || "nil".equals(value); + } + + private String getUrl() { + Jedis jedis = pool.getResource(); + try { + if (jedis != null) { + Client client = jedis.getClient(); + return client.getHost() + ":" + client.getPort() + "/" + client.getDB(); + } + } catch (JedisException e) { + pool.returnBrokenResource(jedis); + jedis = null; + } finally { + if (jedis != null) { + pool.returnResource(jedis); + } + } + return null; + } + + /** + * Ensures that we have a ticket for this ticket id. + * + * @param repository + * @param ticketId + * @return true if the ticket exists + */ + @Override + public boolean hasTicket(RepositoryModel repository, long ticketId) { + if (ticketId <= 0L) { + return false; + } + Jedis jedis = pool.getResource(); + if (jedis == null) { + return false; + } + try { + Boolean exists = jedis.exists(key(repository, KeyType.journal, ticketId)); + return exists != null && !exists; + } catch (JedisException e) { + log.error("failed to check hasTicket from Redis @ " + getUrl(), e); + pool.returnBrokenResource(jedis); + jedis = null; + } finally { + if (jedis != null) { + pool.returnResource(jedis); + } + } + return false; + } + + /** + * Assigns a new ticket id. + * + * @param repository + * @return a new long ticket id + */ + @Override + public synchronized long assignNewId(RepositoryModel repository) { + Jedis jedis = pool.getResource(); + try { + String key = key(repository, KeyType.counter, null); + String val = jedis.get(key); + if (isNull(val)) { + jedis.set(key, "0"); + } + long ticketNumber = jedis.incr(key); + return ticketNumber; + } catch (JedisException e) { + log.error("failed to assign new ticket id in Redis @ " + getUrl(), e); + pool.returnBrokenResource(jedis); + jedis = null; + } finally { + if (jedis != null) { + pool.returnResource(jedis); + } + } + return 0L; + } + + /** + * Returns all the tickets in the repository. Querying tickets from the + * repository requires deserializing all tickets. This is an expensive + * process and not recommended. Tickets should be indexed by Lucene and + * queries should be executed against that index. + * + * @param repository + * @param filter + * optional filter to only return matching results + * @return a list of tickets + */ + @Override + public List<TicketModel> getTickets(RepositoryModel repository, TicketFilter filter) { + Jedis jedis = pool.getResource(); + List<TicketModel> list = new ArrayList<TicketModel>(); + if (jedis == null) { + return list; + } + try { + // Deserialize each journal, build the ticket, and optionally filter + Set<String> keys = jedis.keys(key(repository, KeyType.journal, "*")); + for (String key : keys) { + // {repo}:journal:{id} + String id = key.split(":")[2]; + long ticketId = Long.parseLong(id); + List<Change> changes = getJournal(jedis, repository, ticketId); + if (ArrayUtils.isEmpty(changes)) { + log.warn("Empty journal for {}:{}", repository, ticketId); + continue; + } + TicketModel ticket = TicketModel.buildTicket(changes); + ticket.project = repository.projectPath; + ticket.repository = repository.name; + ticket.number = ticketId; + + // add the ticket, conditionally, to the list + if (filter == null) { + list.add(ticket); + } else { + if (filter.accept(ticket)) { + list.add(ticket); + } + } + } + + // sort the tickets by creation + Collections.sort(list); + } catch (JedisException e) { + log.error("failed to retrieve tickets from Redis @ " + getUrl(), e); + pool.returnBrokenResource(jedis); + jedis = null; + } finally { + if (jedis != null) { + pool.returnResource(jedis); + } + } + return list; + } + + /** + * Retrieves the ticket from the repository by first looking-up the changeId + * associated with the ticketId. + * + * @param repository + * @param ticketId + * @return a ticket, if it exists, otherwise null + */ + @Override + protected TicketModel getTicketImpl(RepositoryModel repository, long ticketId) { + Jedis jedis = pool.getResource(); + if (jedis == null) { + return null; + } + + try { + List<Change> changes = getJournal(jedis, repository, ticketId); + if (ArrayUtils.isEmpty(changes)) { + log.warn("Empty journal for {}:{}", repository, ticketId); + return null; + } + TicketModel ticket = TicketModel.buildTicket(changes); + ticket.project = repository.projectPath; + ticket.repository = repository.name; + ticket.number = ticketId; + log.debug("rebuilt ticket {} from Redis @ {}", ticketId, getUrl()); + return ticket; + } catch (JedisException e) { + log.error("failed to retrieve ticket from Redis @ " + getUrl(), e); + pool.returnBrokenResource(jedis); + jedis = null; + } finally { + if (jedis != null) { + pool.returnResource(jedis); + } + } + return null; + } + + /** + * Returns the journal for the specified ticket. + * + * @param repository + * @param ticketId + * @return a list of changes + */ + private List<Change> getJournal(Jedis jedis, RepositoryModel repository, long ticketId) throws JedisException { + if (ticketId <= 0L) { + return new ArrayList<Change>(); + } + List<String> entries = jedis.lrange(key(repository, KeyType.journal, ticketId), 0, -1); + if (entries.size() > 0) { + // build a json array from the individual entries + StringBuilder sb = new StringBuilder(); + sb.append("["); + for (String entry : entries) { + sb.append(entry).append(','); + } + sb.setLength(sb.length() - 1); + sb.append(']'); + String journal = sb.toString(); + + return TicketSerializer.deserializeJournal(journal); + } + return new ArrayList<Change>(); + } + + @Override + public boolean supportsAttachments() { + return false; + } + + /** + * Retrieves the specified attachment from a ticket. + * + * @param repository + * @param ticketId + * @param filename + * @return an attachment, if found, null otherwise + */ + @Override + public Attachment getAttachment(RepositoryModel repository, long ticketId, String filename) { + return null; + } + + /** + * Deletes a ticket. + * + * @param ticket + * @return true if successful + */ + @Override + protected boolean deleteTicketImpl(RepositoryModel repository, TicketModel ticket, String deletedBy) { + boolean success = false; + if (ticket == null) { + throw new RuntimeException("must specify a ticket!"); + } + + Jedis jedis = pool.getResource(); + if (jedis == null) { + return false; + } + + try { + // atomically remove ticket + Transaction t = jedis.multi(); + t.del(key(repository, KeyType.ticket, ticket.number)); + t.del(key(repository, KeyType.journal, ticket.number)); + t.exec(); + + success = true; + log.debug("deleted ticket {} from Redis @ {}", "" + ticket.number, getUrl()); + } catch (JedisException e) { + log.error("failed to delete ticket from Redis @ " + getUrl(), e); + pool.returnBrokenResource(jedis); + jedis = null; + } finally { + if (jedis != null) { + pool.returnResource(jedis); + } + } + + return success; + } + + /** + * Commit a ticket change to the repository. + * + * @param repository + * @param ticketId + * @param change + * @return true, if the change was committed + */ + @Override + protected boolean commitChangeImpl(RepositoryModel repository, long ticketId, Change change) { + Jedis jedis = pool.getResource(); + if (jedis == null) { + return false; + } + try { + List<Change> changes = getJournal(jedis, repository, ticketId); + changes.add(change); + // build a new effective ticket from the changes + TicketModel ticket = TicketModel.buildTicket(changes); + + String object = TicketSerializer.serialize(ticket); + String journal = TicketSerializer.serialize(change); + + // atomically store ticket + Transaction t = jedis.multi(); + t.set(key(repository, KeyType.ticket, ticketId), object); + t.rpush(key(repository, KeyType.journal, ticketId), journal); + t.exec(); + + log.debug("updated ticket {} in Redis @ {}", "" + ticketId, getUrl()); + return true; + } catch (JedisException e) { + log.error("failed to update ticket cache in Redis @ " + getUrl(), e); + pool.returnBrokenResource(jedis); + jedis = null; + } finally { + if (jedis != null) { + pool.returnResource(jedis); + } + } + return false; + } + + /** + * Deletes all Tickets for the rpeository from the Redis key-value store. + * + */ + @Override + protected boolean deleteAllImpl(RepositoryModel repository) { + Jedis jedis = pool.getResource(); + if (jedis == null) { + return false; + } + + boolean success = false; + try { + Set<String> keys = jedis.keys(repository.name + ":*"); + if (keys.size() > 0) { + Transaction t = jedis.multi(); + t.del(keys.toArray(new String[keys.size()])); + t.exec(); + } + success = true; + } catch (JedisException e) { + log.error("failed to delete all tickets in Redis @ " + getUrl(), e); + pool.returnBrokenResource(jedis); + jedis = null; + } finally { + if (jedis != null) { + pool.returnResource(jedis); + } + } + return success; + } + + @Override + protected boolean renameImpl(RepositoryModel oldRepository, RepositoryModel newRepository) { + Jedis jedis = pool.getResource(); + if (jedis == null) { + return false; + } + + boolean success = false; + try { + Set<String> oldKeys = jedis.keys(oldRepository.name + ":*"); + Transaction t = jedis.multi(); + for (String oldKey : oldKeys) { + String newKey = newRepository.name + oldKey.substring(oldKey.indexOf(':')); + t.rename(oldKey, newKey); + } + t.exec(); + success = true; + } catch (JedisException e) { + log.error("failed to rename tickets in Redis @ " + getUrl(), e); + pool.returnBrokenResource(jedis); + jedis = null; + } finally { + if (jedis != null) { + pool.returnResource(jedis); + } + } + return success; + } + + private JedisPool createPool(String url) { + JedisPool pool = null; + if (!StringUtils.isEmpty(url)) { + try { + URI uri = URI.create(url); + if (uri.getScheme() != null && uri.getScheme().equalsIgnoreCase("redis")) { + int database = Protocol.DEFAULT_DATABASE; + String password = null; + if (uri.getUserInfo() != null) { + password = uri.getUserInfo().split(":", 2)[1]; + } + if (uri.getPath().indexOf('/') > -1) { + database = Integer.parseInt(uri.getPath().split("/", 2)[1]); + } + pool = new JedisPool(new GenericObjectPoolConfig(), uri.getHost(), uri.getPort(), Protocol.DEFAULT_TIMEOUT, password, database); + } else { + pool = new JedisPool(url); + } + } catch (JedisException e) { + log.error("failed to create a Redis pool!", e); + } + } + return pool; + } + + @Override + public String toString() { + String url = getUrl(); + return getClass().getSimpleName() + " (" + (url == null ? "DISABLED" : url) + ")"; + } +} diff --git a/src/main/java/com/gitblit/tickets/TicketIndexer.java b/src/main/java/com/gitblit/tickets/TicketIndexer.java new file mode 100644 index 00000000..3929a000 --- /dev/null +++ b/src/main/java/com/gitblit/tickets/TicketIndexer.java @@ -0,0 +1,657 @@ +/* + * Copyright 2014 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.tickets; + +import java.io.File; +import java.io.IOException; +import java.text.MessageFormat; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.apache.lucene.analysis.standard.StandardAnalyzer; +import org.apache.lucene.document.Document; +import org.apache.lucene.document.Field.Store; +import org.apache.lucene.document.IntField; +import org.apache.lucene.document.LongField; +import org.apache.lucene.document.TextField; +import org.apache.lucene.index.DirectoryReader; +import org.apache.lucene.index.IndexWriter; +import org.apache.lucene.index.IndexWriterConfig; +import org.apache.lucene.index.IndexWriterConfig.OpenMode; +import org.apache.lucene.queryparser.classic.QueryParser; +import org.apache.lucene.search.BooleanClause.Occur; +import org.apache.lucene.search.BooleanQuery; +import org.apache.lucene.search.IndexSearcher; +import org.apache.lucene.search.Query; +import org.apache.lucene.search.ScoreDoc; +import org.apache.lucene.search.Sort; +import org.apache.lucene.search.SortField; +import org.apache.lucene.search.SortField.Type; +import org.apache.lucene.search.TopFieldDocs; +import org.apache.lucene.search.TopScoreDocCollector; +import org.apache.lucene.store.Directory; +import org.apache.lucene.store.FSDirectory; +import org.apache.lucene.util.Version; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.gitblit.Keys; +import com.gitblit.manager.IRuntimeManager; +import com.gitblit.models.RepositoryModel; +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Attachment; +import com.gitblit.models.TicketModel.Patchset; +import com.gitblit.models.TicketModel.Status; +import com.gitblit.utils.FileUtils; +import com.gitblit.utils.StringUtils; + +/** + * Indexes tickets in a Lucene database. + * + * @author James Moger + * + */ +public class TicketIndexer { + + /** + * Fields in the Lucene index + */ + public static enum Lucene { + + rid(Type.STRING), + did(Type.STRING), + project(Type.STRING), + repository(Type.STRING), + number(Type.LONG), + title(Type.STRING), + body(Type.STRING), + topic(Type.STRING), + created(Type.LONG), + createdby(Type.STRING), + updated(Type.LONG), + updatedby(Type.STRING), + responsible(Type.STRING), + milestone(Type.STRING), + status(Type.STRING), + type(Type.STRING), + labels(Type.STRING), + participants(Type.STRING), + watchedby(Type.STRING), + mentions(Type.STRING), + attachments(Type.INT), + content(Type.STRING), + patchset(Type.STRING), + comments(Type.INT), + mergesha(Type.STRING), + mergeto(Type.STRING), + patchsets(Type.INT), + votes(Type.INT); + + final Type fieldType; + + Lucene(Type fieldType) { + this.fieldType = fieldType; + } + + public String colon() { + return name() + ":"; + } + + public String matches(String value) { + if (StringUtils.isEmpty(value)) { + return ""; + } + boolean not = value.charAt(0) == '!'; + if (not) { + return "!" + name() + ":" + escape(value.substring(1)); + } + return name() + ":" + escape(value); + } + + public String doesNotMatch(String value) { + if (StringUtils.isEmpty(value)) { + return ""; + } + return "NOT " + name() + ":" + escape(value); + } + + public String isNotNull() { + return matches("[* TO *]"); + } + + public SortField asSortField(boolean descending) { + return new SortField(name(), fieldType, descending); + } + + private String escape(String value) { + if (value.charAt(0) != '"') { + if (value.indexOf('/') > -1) { + return "\"" + value + "\""; + } + } + return value; + } + + public static Lucene fromString(String value) { + for (Lucene field : values()) { + if (field.name().equalsIgnoreCase(value)) { + return field; + } + } + return created; + } + } + + private final Logger log = LoggerFactory.getLogger(getClass()); + + private final Version luceneVersion = Version.LUCENE_46; + + private final File luceneDir; + + private IndexWriter writer; + + private IndexSearcher searcher; + + public TicketIndexer(IRuntimeManager runtimeManager) { + this.luceneDir = runtimeManager.getFileOrFolder(Keys.tickets.indexFolder, "${baseFolder}/tickets/lucene"); + } + + /** + * Close all writers and searchers used by the ticket indexer. + */ + public void close() { + closeSearcher(); + closeWriter(); + } + + /** + * Deletes the entire ticket index for all repositories. + */ + public void deleteAll() { + close(); + FileUtils.delete(luceneDir); + } + + /** + * Deletes all tickets for the the repository from the index. + */ + public boolean deleteAll(RepositoryModel repository) { + try { + IndexWriter writer = getWriter(); + StandardAnalyzer analyzer = new StandardAnalyzer(luceneVersion); + QueryParser qp = new QueryParser(luceneVersion, Lucene.rid.name(), analyzer); + BooleanQuery query = new BooleanQuery(); + query.add(qp.parse(repository.getRID()), Occur.MUST); + + int numDocsBefore = writer.numDocs(); + writer.deleteDocuments(query); + writer.commit(); + closeSearcher(); + int numDocsAfter = writer.numDocs(); + if (numDocsBefore == numDocsAfter) { + log.debug(MessageFormat.format("no records found to delete in {0}", repository)); + return false; + } else { + log.debug(MessageFormat.format("deleted {0} records in {1}", numDocsBefore - numDocsAfter, repository)); + return true; + } + } catch (Exception e) { + log.error("error", e); + } + return false; + } + + /** + * Bulk Add/Update tickets in the Lucene index + * + * @param tickets + */ + public void index(List<TicketModel> tickets) { + try { + IndexWriter writer = getWriter(); + for (TicketModel ticket : tickets) { + Document doc = ticketToDoc(ticket); + writer.addDocument(doc); + } + writer.commit(); + closeSearcher(); + } catch (Exception e) { + log.error("error", e); + } + } + + /** + * Add/Update a ticket in the Lucene index + * + * @param ticket + */ + public void index(TicketModel ticket) { + try { + IndexWriter writer = getWriter(); + delete(ticket.repository, ticket.number, writer); + Document doc = ticketToDoc(ticket); + writer.addDocument(doc); + writer.commit(); + closeSearcher(); + } catch (Exception e) { + log.error("error", e); + } + } + + /** + * Delete a ticket from the Lucene index. + * + * @param ticket + * @throws Exception + * @return true, if deleted, false if no record was deleted + */ + public boolean delete(TicketModel ticket) { + try { + IndexWriter writer = getWriter(); + return delete(ticket.repository, ticket.number, writer); + } catch (Exception e) { + log.error("Failed to delete ticket " + ticket.number, e); + } + return false; + } + + /** + * Delete a ticket from the Lucene index. + * + * @param repository + * @param ticketId + * @throws Exception + * @return true, if deleted, false if no record was deleted + */ + private boolean delete(String repository, long ticketId, IndexWriter writer) throws Exception { + StandardAnalyzer analyzer = new StandardAnalyzer(luceneVersion); + QueryParser qp = new QueryParser(luceneVersion, Lucene.did.name(), analyzer); + BooleanQuery query = new BooleanQuery(); + query.add(qp.parse(StringUtils.getSHA1(repository + ticketId)), Occur.MUST); + + int numDocsBefore = writer.numDocs(); + writer.deleteDocuments(query); + writer.commit(); + closeSearcher(); + int numDocsAfter = writer.numDocs(); + if (numDocsBefore == numDocsAfter) { + log.debug(MessageFormat.format("no records found to delete in {0}", repository)); + return false; + } else { + log.debug(MessageFormat.format("deleted {0} records in {1}", numDocsBefore - numDocsAfter, repository)); + return true; + } + } + + /** + * Returns true if the repository has tickets in the index. + * + * @param repository + * @return true if there are indexed tickets + */ + public boolean hasTickets(RepositoryModel repository) { + return !queryFor(Lucene.rid.matches(repository.getRID()), 1, 0, null, true).isEmpty(); + } + + /** + * Search for tickets matching the query. The returned tickets are + * shadows of the real ticket, but suitable for a results list. + * + * @param repository + * @param text + * @param page + * @param pageSize + * @return search results + */ + public List<QueryResult> searchFor(RepositoryModel repository, String text, int page, int pageSize) { + if (StringUtils.isEmpty(text)) { + return Collections.emptyList(); + } + Set<QueryResult> results = new LinkedHashSet<QueryResult>(); + StandardAnalyzer analyzer = new StandardAnalyzer(luceneVersion); + try { + // search the title, description and content + BooleanQuery query = new BooleanQuery(); + QueryParser qp; + + qp = new QueryParser(luceneVersion, Lucene.title.name(), analyzer); + qp.setAllowLeadingWildcard(true); + query.add(qp.parse(text), Occur.SHOULD); + + qp = new QueryParser(luceneVersion, Lucene.body.name(), analyzer); + qp.setAllowLeadingWildcard(true); + query.add(qp.parse(text), Occur.SHOULD); + + qp = new QueryParser(luceneVersion, Lucene.content.name(), analyzer); + qp.setAllowLeadingWildcard(true); + query.add(qp.parse(text), Occur.SHOULD); + + IndexSearcher searcher = getSearcher(); + Query rewrittenQuery = searcher.rewrite(query); + + log.debug(rewrittenQuery.toString()); + + TopScoreDocCollector collector = TopScoreDocCollector.create(5000, true); + searcher.search(rewrittenQuery, collector); + int offset = Math.max(0, (page - 1) * pageSize); + ScoreDoc[] hits = collector.topDocs(offset, pageSize).scoreDocs; + for (int i = 0; i < hits.length; i++) { + int docId = hits[i].doc; + Document doc = searcher.doc(docId); + QueryResult result = docToQueryResult(doc); + if (repository != null) { + if (!result.repository.equalsIgnoreCase(repository.name)) { + continue; + } + } + results.add(result); + } + } catch (Exception e) { + log.error(MessageFormat.format("Exception while searching for {0}", text), e); + } + return new ArrayList<QueryResult>(results); + } + + /** + * Search for tickets matching the query. The returned tickets are + * shadows of the real ticket, but suitable for a results list. + * + * @param text + * @param page + * @param pageSize + * @param sortBy + * @param desc + * @return + */ + public List<QueryResult> queryFor(String queryText, int page, int pageSize, String sortBy, boolean desc) { + if (StringUtils.isEmpty(queryText)) { + return Collections.emptyList(); + } + + Set<QueryResult> results = new LinkedHashSet<QueryResult>(); + StandardAnalyzer analyzer = new StandardAnalyzer(luceneVersion); + try { + QueryParser qp = new QueryParser(luceneVersion, Lucene.content.name(), analyzer); + Query query = qp.parse(queryText); + + IndexSearcher searcher = getSearcher(); + Query rewrittenQuery = searcher.rewrite(query); + + log.debug(rewrittenQuery.toString()); + + Sort sort; + if (sortBy == null) { + sort = new Sort(Lucene.created.asSortField(desc)); + } else { + sort = new Sort(Lucene.fromString(sortBy).asSortField(desc)); + } + int maxSize = 5000; + TopFieldDocs docs = searcher.search(rewrittenQuery, null, maxSize, sort, false, false); + int size = (pageSize <= 0) ? maxSize : pageSize; + int offset = Math.max(0, (page - 1) * size); + ScoreDoc[] hits = subset(docs.scoreDocs, offset, size); + for (int i = 0; i < hits.length; i++) { + int docId = hits[i].doc; + Document doc = searcher.doc(docId); + QueryResult result = docToQueryResult(doc); + result.docId = docId; + result.totalResults = docs.totalHits; + results.add(result); + } + } catch (Exception e) { + log.error(MessageFormat.format("Exception while searching for {0}", queryText), e); + } + return new ArrayList<QueryResult>(results); + } + + private ScoreDoc [] subset(ScoreDoc [] docs, int offset, int size) { + if (docs.length >= (offset + size)) { + ScoreDoc [] set = new ScoreDoc[size]; + System.arraycopy(docs, offset, set, 0, set.length); + return set; + } else if (docs.length >= offset) { + ScoreDoc [] set = new ScoreDoc[docs.length - offset]; + System.arraycopy(docs, offset, set, 0, set.length); + return set; + } else { + return new ScoreDoc[0]; + } + } + + private IndexWriter getWriter() throws IOException { + if (writer == null) { + Directory directory = FSDirectory.open(luceneDir); + + if (!luceneDir.exists()) { + luceneDir.mkdirs(); + } + + StandardAnalyzer analyzer = new StandardAnalyzer(luceneVersion); + IndexWriterConfig config = new IndexWriterConfig(luceneVersion, analyzer); + config.setOpenMode(OpenMode.CREATE_OR_APPEND); + writer = new IndexWriter(directory, config); + } + return writer; + } + + private synchronized void closeWriter() { + try { + if (writer != null) { + writer.close(); + } + } catch (Exception e) { + log.error("failed to close writer!", e); + } finally { + writer = null; + } + } + + private IndexSearcher getSearcher() throws IOException { + if (searcher == null) { + searcher = new IndexSearcher(DirectoryReader.open(getWriter(), true)); + } + return searcher; + } + + private synchronized void closeSearcher() { + try { + if (searcher != null) { + searcher.getIndexReader().close(); + } + } catch (Exception e) { + log.error("failed to close searcher!", e); + } finally { + searcher = null; + } + } + + /** + * Creates a Lucene document from a ticket. + * + * @param ticket + * @return a Lucene document + */ + private Document ticketToDoc(TicketModel ticket) { + Document doc = new Document(); + // repository and document ids for Lucene querying + toDocField(doc, Lucene.rid, StringUtils.getSHA1(ticket.repository)); + toDocField(doc, Lucene.did, StringUtils.getSHA1(ticket.repository + ticket.number)); + + toDocField(doc, Lucene.project, ticket.project); + toDocField(doc, Lucene.repository, ticket.repository); + toDocField(doc, Lucene.number, ticket.number); + toDocField(doc, Lucene.title, ticket.title); + toDocField(doc, Lucene.body, ticket.body); + toDocField(doc, Lucene.created, ticket.created); + toDocField(doc, Lucene.createdby, ticket.createdBy); + toDocField(doc, Lucene.updated, ticket.updated); + toDocField(doc, Lucene.updatedby, ticket.updatedBy); + toDocField(doc, Lucene.responsible, ticket.responsible); + toDocField(doc, Lucene.milestone, ticket.milestone); + toDocField(doc, Lucene.topic, ticket.topic); + toDocField(doc, Lucene.status, ticket.status.name()); + toDocField(doc, Lucene.comments, ticket.getComments().size()); + toDocField(doc, Lucene.type, ticket.type == null ? null : ticket.type.name()); + toDocField(doc, Lucene.mergesha, ticket.mergeSha); + toDocField(doc, Lucene.mergeto, ticket.mergeTo); + toDocField(doc, Lucene.labels, StringUtils.flattenStrings(ticket.getLabels(), ";").toLowerCase()); + toDocField(doc, Lucene.participants, StringUtils.flattenStrings(ticket.getParticipants(), ";").toLowerCase()); + toDocField(doc, Lucene.watchedby, StringUtils.flattenStrings(ticket.getWatchers(), ";").toLowerCase()); + toDocField(doc, Lucene.mentions, StringUtils.flattenStrings(ticket.getMentions(), ";").toLowerCase()); + toDocField(doc, Lucene.votes, ticket.getVoters().size()); + + List<String> attachments = new ArrayList<String>(); + for (Attachment attachment : ticket.getAttachments()) { + attachments.add(attachment.name.toLowerCase()); + } + toDocField(doc, Lucene.attachments, StringUtils.flattenStrings(attachments, ";")); + + List<Patchset> patches = ticket.getPatchsets(); + if (!patches.isEmpty()) { + toDocField(doc, Lucene.patchsets, patches.size()); + Patchset patchset = patches.get(patches.size() - 1); + String flat = + patchset.number + ":" + + patchset.rev + ":" + + patchset.tip + ":" + + patchset.base + ":" + + patchset.commits; + doc.add(new org.apache.lucene.document.Field(Lucene.patchset.name(), flat, TextField.TYPE_STORED)); + } + + doc.add(new TextField(Lucene.content.name(), ticket.toIndexableString(), Store.NO)); + + return doc; + } + + private void toDocField(Document doc, Lucene lucene, Date value) { + if (value == null) { + return; + } + doc.add(new LongField(lucene.name(), value.getTime(), Store.YES)); + } + + private void toDocField(Document doc, Lucene lucene, long value) { + doc.add(new LongField(lucene.name(), value, Store.YES)); + } + + private void toDocField(Document doc, Lucene lucene, int value) { + doc.add(new IntField(lucene.name(), value, Store.YES)); + } + + private void toDocField(Document doc, Lucene lucene, String value) { + if (StringUtils.isEmpty(value)) { + return; + } + doc.add(new org.apache.lucene.document.Field(lucene.name(), value, TextField.TYPE_STORED)); + } + + /** + * Creates a query result from the Lucene document. This result is + * not a high-fidelity representation of the real ticket, but it is + * suitable for display in a table of search results. + * + * @param doc + * @return a query result + * @throws ParseException + */ + private QueryResult docToQueryResult(Document doc) throws ParseException { + QueryResult result = new QueryResult(); + result.project = unpackString(doc, Lucene.project); + result.repository = unpackString(doc, Lucene.repository); + result.number = unpackLong(doc, Lucene.number); + result.createdBy = unpackString(doc, Lucene.createdby); + result.createdAt = unpackDate(doc, Lucene.created); + result.updatedBy = unpackString(doc, Lucene.updatedby); + result.updatedAt = unpackDate(doc, Lucene.updated); + result.title = unpackString(doc, Lucene.title); + result.body = unpackString(doc, Lucene.body); + result.status = Status.fromObject(unpackString(doc, Lucene.status), Status.New); + result.responsible = unpackString(doc, Lucene.responsible); + result.milestone = unpackString(doc, Lucene.milestone); + result.topic = unpackString(doc, Lucene.topic); + result.type = TicketModel.Type.fromObject(unpackString(doc, Lucene.type), TicketModel.Type.defaultType); + result.mergeSha = unpackString(doc, Lucene.mergesha); + result.mergeTo = unpackString(doc, Lucene.mergeto); + result.commentsCount = unpackInt(doc, Lucene.comments); + result.votesCount = unpackInt(doc, Lucene.votes); + result.attachments = unpackStrings(doc, Lucene.attachments); + result.labels = unpackStrings(doc, Lucene.labels); + result.participants = unpackStrings(doc, Lucene.participants); + result.watchedby = unpackStrings(doc, Lucene.watchedby); + result.mentions = unpackStrings(doc, Lucene.mentions); + + if (!StringUtils.isEmpty(doc.get(Lucene.patchset.name()))) { + // unpack most recent patchset + String [] values = doc.get(Lucene.patchset.name()).split(":", 5); + + Patchset patchset = new Patchset(); + patchset.number = Integer.parseInt(values[0]); + patchset.rev = Integer.parseInt(values[1]); + patchset.tip = values[2]; + patchset.base = values[3]; + patchset.commits = Integer.parseInt(values[4]); + + result.patchset = patchset; + } + + return result; + } + + private String unpackString(Document doc, Lucene lucene) { + return doc.get(lucene.name()); + } + + private List<String> unpackStrings(Document doc, Lucene lucene) { + if (!StringUtils.isEmpty(doc.get(lucene.name()))) { + return StringUtils.getStringsFromValue(doc.get(lucene.name()), ";"); + } + return null; + } + + private Date unpackDate(Document doc, Lucene lucene) { + String val = doc.get(lucene.name()); + if (!StringUtils.isEmpty(val)) { + long time = Long.parseLong(val); + Date date = new Date(time); + return date; + } + return null; + } + + private long unpackLong(Document doc, Lucene lucene) { + String val = doc.get(lucene.name()); + if (StringUtils.isEmpty(val)) { + return 0; + } + long l = Long.parseLong(val); + return l; + } + + private int unpackInt(Document doc, Lucene lucene) { + String val = doc.get(lucene.name()); + if (StringUtils.isEmpty(val)) { + return 0; + } + int i = Integer.parseInt(val); + return i; + } +}
\ No newline at end of file diff --git a/src/main/java/com/gitblit/tickets/TicketLabel.java b/src/main/java/com/gitblit/tickets/TicketLabel.java new file mode 100644 index 00000000..686ce88b --- /dev/null +++ b/src/main/java/com/gitblit/tickets/TicketLabel.java @@ -0,0 +1,77 @@ +/* + * Copyright 2013 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.tickets; + +import java.io.Serializable; +import java.util.List; + +import com.gitblit.utils.StringUtils; + +/** + * A ticket label. + * + * @author James Moger + * + */ +public class TicketLabel implements Serializable { + + private static final long serialVersionUID = 1L; + + public final String name; + + public String color; + + public List<QueryResult> tickets; + + + public TicketLabel(String name) { + this.name = name; + this.color = StringUtils.getColor(name); + } + + public int getTotalTickets() { + return tickets == null ? 0 : tickets.size(); + } + + public int getOpenTickets() { + int cnt = 0; + if (tickets != null) { + for (QueryResult ticket : tickets) { + if (!ticket.status.isClosed()) { + cnt++; + } + } + } + return cnt; + } + + public int getClosedTickets() { + int cnt = 0; + if (tickets != null) { + for (QueryResult ticket : tickets) { + if (ticket.status.isClosed()) { + cnt++; + } + } + } + return cnt; + } + + @Override + public String toString() { + return name; + } +} diff --git a/src/main/java/com/gitblit/tickets/TicketMilestone.java b/src/main/java/com/gitblit/tickets/TicketMilestone.java new file mode 100644 index 00000000..c6b4fcca --- /dev/null +++ b/src/main/java/com/gitblit/tickets/TicketMilestone.java @@ -0,0 +1,53 @@ +/* + * Copyright 2013 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.tickets; + +import java.util.Date; + +import com.gitblit.models.TicketModel.Status; + +/** + * A ticket milestone. + * + * @author James Moger + * + */ +public class TicketMilestone extends TicketLabel { + + private static final long serialVersionUID = 1L; + + public Status status; + + public Date due; + + public TicketMilestone(String name) { + super(name); + status = Status.Open; + } + + public int getProgress() { + int total = getTotalTickets(); + if (total == 0) { + return 0; + } + return (int) (((getClosedTickets() * 1f) / (total * 1f)) * 100); + } + + @Override + public String toString() { + return name; + } +} diff --git a/src/main/java/com/gitblit/tickets/TicketNotifier.java b/src/main/java/com/gitblit/tickets/TicketNotifier.java new file mode 100644 index 00000000..b4c3baeb --- /dev/null +++ b/src/main/java/com/gitblit/tickets/TicketNotifier.java @@ -0,0 +1,617 @@ +/* + * Copyright 2014 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.tickets; + +import java.io.IOException; +import java.io.InputStream; +import java.text.DateFormat; +import java.text.MessageFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.io.IOUtils; +import org.apache.log4j.Logger; +import org.eclipse.jgit.diff.DiffEntry.ChangeType; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.slf4j.LoggerFactory; + +import com.gitblit.Constants; +import com.gitblit.IStoredSettings; +import com.gitblit.Keys; +import com.gitblit.git.PatchsetCommand; +import com.gitblit.manager.INotificationManager; +import com.gitblit.manager.IRepositoryManager; +import com.gitblit.manager.IRuntimeManager; +import com.gitblit.manager.IUserManager; +import com.gitblit.models.Mailing; +import com.gitblit.models.PathModel.PathChangeModel; +import com.gitblit.models.RepositoryModel; +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Change; +import com.gitblit.models.TicketModel.Field; +import com.gitblit.models.TicketModel.Patchset; +import com.gitblit.models.TicketModel.Review; +import com.gitblit.models.TicketModel.Status; +import com.gitblit.models.UserModel; +import com.gitblit.utils.ArrayUtils; +import com.gitblit.utils.DiffUtils; +import com.gitblit.utils.DiffUtils.DiffStat; +import com.gitblit.utils.JGitUtils; +import com.gitblit.utils.MarkdownUtils; +import com.gitblit.utils.StringUtils; + +/** + * Formats and queues ticket/patch notifications for dispatch to the + * mail executor upon completion of a push or a ticket update. Messages are + * created as Markdown and then transformed to html. + * + * @author James Moger + * + */ +public class TicketNotifier { + + protected final Map<Long, Mailing> queue = new TreeMap<Long, Mailing>(); + + private final String SOFT_BRK = "\n"; + + private final String HARD_BRK = "\n\n"; + + private final String HR = "----\n\n"; + + private final IStoredSettings settings; + + private final INotificationManager notificationManager; + + private final IUserManager userManager; + + private final IRepositoryManager repositoryManager; + + private final ITicketService ticketService; + + private final String addPattern = "<span style=\"color:darkgreen;\">+{0}</span>"; + private final String delPattern = "<span style=\"color:darkred;\">-{0}</span>"; + + public TicketNotifier( + IRuntimeManager runtimeManager, + INotificationManager notificationManager, + IUserManager userManager, + IRepositoryManager repositoryManager, + ITicketService ticketService) { + + this.settings = runtimeManager.getSettings(); + this.notificationManager = notificationManager; + this.userManager = userManager; + this.repositoryManager = repositoryManager; + this.ticketService = ticketService; + } + + public void sendAll() { + for (Mailing mail : queue.values()) { + notificationManager.send(mail); + } + } + + public void sendMailing(TicketModel ticket) { + queueMailing(ticket); + sendAll(); + } + + /** + * Queues an update notification. + * + * @param ticket + * @return a notification object used for testing + */ + public Mailing queueMailing(TicketModel ticket) { + try { + // format notification message + String markdown = formatLastChange(ticket); + + StringBuilder html = new StringBuilder(); + html.append("<head>"); + html.append(readStyle()); + html.append("</head>"); + html.append("<body>"); + html.append(MarkdownUtils.transformGFM(settings, markdown, ticket.repository)); + html.append("</body>"); + + Mailing mailing = Mailing.newHtml(); + mailing.from = getUserModel(ticket.updatedBy == null ? ticket.createdBy : ticket.updatedBy).getDisplayName(); + mailing.subject = getSubject(ticket); + mailing.content = html.toString(); + mailing.id = "ticket." + ticket.number + "." + StringUtils.getSHA1(ticket.repository + ticket.number); + + setRecipients(ticket, mailing); + queue.put(ticket.number, mailing); + + return mailing; + } catch (Exception e) { + Logger.getLogger(getClass()).error("failed to queue mailing for #" + ticket.number, e); + } + return null; + } + + protected String getSubject(TicketModel ticket) { + Change lastChange = ticket.changes.get(ticket.changes.size() - 1); + boolean newTicket = lastChange.isStatusChange() && ticket.changes.size() == 1; + String re = newTicket ? "" : "Re: "; + String subject = MessageFormat.format("{0}[{1}] {2} (#{3,number,0})", + re, StringUtils.stripDotGit(ticket.repository), ticket.title, ticket.number); + return subject; + } + + protected String formatLastChange(TicketModel ticket) { + Change lastChange = ticket.changes.get(ticket.changes.size() - 1); + UserModel user = getUserModel(lastChange.author); + + // define the fields we do NOT want to see in an email notification + Set<TicketModel.Field> fieldExclusions = new HashSet<TicketModel.Field>(); + fieldExclusions.addAll(Arrays.asList(Field.watchers, Field.voters)); + + StringBuilder sb = new StringBuilder(); + boolean newTicket = false; + boolean isFastForward = true; + List<RevCommit> commits = null; + DiffStat diffstat = null; + + String pattern; + if (lastChange.isStatusChange()) { + Status state = lastChange.getStatus(); + switch (state) { + case New: + // new ticket + newTicket = true; + fieldExclusions.add(Field.status); + fieldExclusions.add(Field.title); + fieldExclusions.add(Field.body); + if (lastChange.hasPatchset()) { + pattern = "**{0}** is proposing a change."; + } else { + pattern = "**{0}** created this ticket."; + } + sb.append(MessageFormat.format(pattern, user.getDisplayName())); + break; + default: + // some form of resolved + if (lastChange.hasField(Field.mergeSha)) { + // closed by push (merged patchset) + pattern = "**{0}** closed this ticket by pushing {1} to {2}."; + + // identify patch that closed the ticket + String merged = ticket.mergeSha; + for (Patchset patchset : ticket.getPatchsets()) { + if (patchset.tip.equals(ticket.mergeSha)) { + merged = patchset.toString(); + break; + } + } + sb.append(MessageFormat.format(pattern, user.getDisplayName(), merged, ticket.mergeTo)); + } else { + // workflow status change by user + pattern = "**{0}** changed the status of this ticket to **{1}**."; + sb.append(MessageFormat.format(pattern, user.getDisplayName(), lastChange.getStatus().toString().toUpperCase())); + } + break; + } + sb.append(HARD_BRK); + } else if (lastChange.hasPatchset()) { + // patchset uploaded + Patchset patchset = lastChange.patchset; + String base = ""; + // determine the changed paths + Repository repo = null; + try { + repo = repositoryManager.getRepository(ticket.repository); + if (patchset.isFF() && (patchset.rev > 1)) { + // fast-forward update, just show the new data + isFastForward = true; + Patchset prev = ticket.getPatchset(patchset.number, patchset.rev - 1); + base = prev.tip; + } else { + // proposal OR non-fast-forward update + isFastForward = false; + base = patchset.base; + } + + diffstat = DiffUtils.getDiffStat(repo, base, patchset.tip); + commits = JGitUtils.getRevLog(repo, base, patchset.tip); + } catch (Exception e) { + Logger.getLogger(getClass()).error("failed to get changed paths", e); + } finally { + repo.close(); + } + + // describe the patchset + String compareUrl = ticketService.getCompareUrl(ticket, base, patchset.tip); + if (patchset.isFF()) { + pattern = "**{0}** added {1} {2} to patchset {3}."; + sb.append(MessageFormat.format(pattern, user.getDisplayName(), patchset.added, patchset.added == 1 ? "commit" : "commits", patchset.number)); + } else { + pattern = "**{0}** uploaded patchset {1}. *({2})*"; + sb.append(MessageFormat.format(pattern, user.getDisplayName(), patchset.number, patchset.type.toString().toUpperCase())); + } + sb.append(HARD_BRK); + sb.append(MessageFormat.format("{0} {1}, {2} {3}, <span style=\"color:darkgreen;\">+{4} insertions</span>, <span style=\"color:darkred;\">-{5} deletions</span> from {6}. [compare]({7})", + commits.size(), commits.size() == 1 ? "commit" : "commits", + diffstat.paths.size(), + diffstat.paths.size() == 1 ? "file" : "files", + diffstat.getInsertions(), + diffstat.getDeletions(), + isFastForward ? "previous revision" : "merge base", + compareUrl)); + + // note commit additions on a rebase,if any + switch (lastChange.patchset.type) { + case Rebase: + if (lastChange.patchset.added > 0) { + sb.append(SOFT_BRK); + sb.append(MessageFormat.format("{0} {1} added.", lastChange.patchset.added, lastChange.patchset.added == 1 ? "commit" : "commits")); + } + break; + default: + break; + } + sb.append(HARD_BRK); + } else if (lastChange.hasReview()) { + // review + Review review = lastChange.review; + pattern = "**{0}** has reviewed patchset {1,number,0} revision {2,number,0}."; + sb.append(MessageFormat.format(pattern, user.getDisplayName(), review.patchset, review.rev)); + sb.append(HARD_BRK); + + String d = settings.getString(Keys.web.datestampShortFormat, "yyyy-MM-dd"); + String t = settings.getString(Keys.web.timeFormat, "HH:mm"); + DateFormat df = new SimpleDateFormat(d + " " + t); + List<Change> reviews = ticket.getReviews(ticket.getPatchset(review.patchset, review.rev)); + sb.append("| Date | Reviewer | Score | Description |\n"); + sb.append("| :--- | :------------ | :---: | :----------- |\n"); + for (Change change : reviews) { + String name = change.author; + UserModel u = userManager.getUserModel(change.author); + if (u != null) { + name = u.getDisplayName(); + } + String score; + switch (change.review.score) { + case approved: + score = MessageFormat.format(addPattern, change.review.score.getValue()); + break; + case vetoed: + score = MessageFormat.format(delPattern, Math.abs(change.review.score.getValue())); + break; + default: + score = "" + change.review.score.getValue(); + } + String date = df.format(change.date); + sb.append(String.format("| %1$s | %2$s | %3$s | %4$s |\n", + date, name, score, change.review.score.toString())); + } + sb.append(HARD_BRK); + } else if (lastChange.hasComment()) { + // comment update + sb.append(MessageFormat.format("**{0}** commented on this ticket.", user.getDisplayName())); + sb.append(HARD_BRK); + } else { + // general update + pattern = "**{0}** has updated this ticket."; + sb.append(MessageFormat.format(pattern, user.getDisplayName())); + sb.append(HARD_BRK); + } + + // ticket link + sb.append(MessageFormat.format("[view ticket {0,number,0}]({1})", + ticket.number, ticketService.getTicketUrl(ticket))); + sb.append(HARD_BRK); + + if (newTicket) { + // ticket title + sb.append(MessageFormat.format("### {0}", ticket.title)); + sb.append(HARD_BRK); + + // ticket description, on state change + if (StringUtils.isEmpty(ticket.body)) { + sb.append("<span style=\"color: #888;\">no description entered</span>"); + } else { + sb.append(ticket.body); + } + sb.append(HARD_BRK); + sb.append(HR); + } + + // field changes + if (lastChange.hasFieldChanges()) { + Map<Field, String> filtered = new HashMap<Field, String>(); + for (Map.Entry<Field, String> fc : lastChange.fields.entrySet()) { + if (!fieldExclusions.contains(fc.getKey())) { + // field is included + filtered.put(fc.getKey(), fc.getValue()); + } + } + + // sort by field ordinal + List<Field> fields = new ArrayList<Field>(filtered.keySet()); + Collections.sort(fields); + + if (filtered.size() > 0) { + sb.append(HARD_BRK); + sb.append("| Field Changes ||\n"); + sb.append("| ------------: | :----------- |\n"); + for (Field field : fields) { + String value; + if (filtered.get(field) == null) { + value = ""; + } else { + value = filtered.get(field).replace("\r\n", "<br/>").replace("\n", "<br/>").replace("|", "|"); + } + sb.append(String.format("| **%1$s:** | %2$s |\n", field.name(), value)); + } + sb.append(HARD_BRK); + } + } + + // new comment + if (lastChange.hasComment()) { + sb.append(HR); + sb.append(lastChange.comment.text); + sb.append(HARD_BRK); + } + + // insert the patchset details and review instructions + if (lastChange.hasPatchset() && ticket.isOpen()) { + if (commits != null && commits.size() > 0) { + // append the commit list + String title = isFastForward ? "Commits added to previous patchset revision" : "All commits in patchset"; + sb.append(MessageFormat.format("| {0} |||\n", title)); + sb.append("| SHA | Author | Title |\n"); + sb.append("| :-- | :----- | :---- |\n"); + for (RevCommit commit : commits) { + sb.append(MessageFormat.format("| {0} | {1} | {2} |\n", + commit.getName(), commit.getAuthorIdent().getName(), + StringUtils.trimString(commit.getShortMessage(), Constants.LEN_SHORTLOG).replace("|", "|"))); + } + sb.append(HARD_BRK); + } + + if (diffstat != null) { + // append the changed path list + String title = isFastForward ? "Files changed since previous patchset revision" : "All files changed in patchset"; + sb.append(MessageFormat.format("| {0} |||\n", title)); + sb.append("| :-- | :----------- | :-: |\n"); + for (PathChangeModel path : diffstat.paths) { + String add = MessageFormat.format(addPattern, path.insertions); + String del = MessageFormat.format(delPattern, path.deletions); + String diff = null; + switch (path.changeType) { + case ADD: + diff = add; + break; + case DELETE: + diff = del; + break; + case MODIFY: + if (path.insertions > 0 && path.deletions > 0) { + // insertions & deletions + diff = add + "/" + del; + } else if (path.insertions > 0) { + // just insertions + diff = add; + } else { + // just deletions + diff = del; + } + break; + default: + diff = path.changeType.name(); + break; + } + sb.append(MessageFormat.format("| {0} | {1} | {2} |\n", + getChangeType(path.changeType), path.name, diff)); + } + sb.append(HARD_BRK); + } + + sb.append(formatPatchsetInstructions(ticket, lastChange.patchset)); + } + + return sb.toString(); + } + + protected String getChangeType(ChangeType type) { + String style = null; + switch (type) { + case ADD: + style = "color:darkgreen;"; + break; + case COPY: + style = ""; + break; + case DELETE: + style = "color:darkred;"; + break; + case MODIFY: + style = ""; + break; + case RENAME: + style = ""; + break; + default: + break; + } + String code = type.name().toUpperCase().substring(0, 1); + if (style == null) { + return code; + } else { + return MessageFormat.format("<strong><span style=\"{0}padding:2px;margin:2px;border:1px solid #ddd;\">{1}</span></strong>", style, code); + } + } + + /** + * Generates patchset review instructions for command-line git + * + * @param patchset + * @return instructions + */ + protected String formatPatchsetInstructions(TicketModel ticket, Patchset patchset) { + String canonicalUrl = settings.getString(Keys.web.canonicalUrl, "https://localhost:8443"); + String repositoryUrl = canonicalUrl + Constants.R_PATH + ticket.repository; + + String ticketBranch = Repository.shortenRefName(PatchsetCommand.getTicketBranch(ticket.number)); + String patchsetBranch = PatchsetCommand.getPatchsetBranch(ticket.number, patchset.number); + String reviewBranch = PatchsetCommand.getReviewBranch(ticket.number); + + String instructions = readResource("commands.md"); + instructions = instructions.replace("${ticketId}", "" + ticket.number); + instructions = instructions.replace("${patchset}", "" + patchset.number); + instructions = instructions.replace("${repositoryUrl}", repositoryUrl); + instructions = instructions.replace("${ticketRef}", ticketBranch); + instructions = instructions.replace("${patchsetRef}", patchsetBranch); + instructions = instructions.replace("${reviewBranch}", reviewBranch); + + return instructions; + } + + /** + * Gets the usermodel for the username. Creates a temp model, if required. + * + * @param username + * @return a usermodel + */ + protected UserModel getUserModel(String username) { + UserModel user = userManager.getUserModel(username); + if (user == null) { + // create a temporary user model (for unit tests) + user = new UserModel(username); + } + return user; + } + + /** + * Set the proper recipients for a ticket. + * + * @param ticket + * @param mailing + */ + protected void setRecipients(TicketModel ticket, Mailing mailing) { + RepositoryModel repository = repositoryManager.getRepositoryModel(ticket.repository); + + // + // Direct TO recipients + // + Set<String> toAddresses = new TreeSet<String>(); + for (String name : ticket.getParticipants()) { + UserModel user = userManager.getUserModel(name); + if (user != null) { + if (!StringUtils.isEmpty(user.emailAddress)) { + if (user.canView(repository)) { + toAddresses.add(user.emailAddress); + } else { + LoggerFactory.getLogger(getClass()).warn( + MessageFormat.format("ticket {0}-{1,number,0}: {2} can not receive notification", + repository.name, ticket.number, user.username)); + } + } + } + } + mailing.setRecipients(toAddresses); + + // + // CC recipients + // + Set<String> ccs = new TreeSet<String>(); + + // cc users mentioned in last comment + Change lastChange = ticket.changes.get(ticket.changes.size() - 1); + if (lastChange.hasComment()) { + Pattern p = Pattern.compile("\\s@([A-Za-z0-9-_]+)"); + Matcher m = p.matcher(lastChange.comment.text); + while (m.find()) { + String username = m.group(); + ccs.add(username); + } + } + + // cc users who are watching the ticket + ccs.addAll(ticket.getWatchers()); + + // TODO cc users who are watching the repository + + Set<String> ccAddresses = new TreeSet<String>(); + for (String name : ccs) { + UserModel user = userManager.getUserModel(name); + if (user != null) { + if (!StringUtils.isEmpty(user.emailAddress)) { + if (user.canView(repository)) { + ccAddresses.add(user.emailAddress); + } else { + LoggerFactory.getLogger(getClass()).warn( + MessageFormat.format("ticket {0}-{1,number,0}: {2} can not receive notification", + repository.name, ticket.number, user.username)); + } + } + } + } + + // cc repository mailing list addresses + if (!ArrayUtils.isEmpty(repository.mailingLists)) { + ccAddresses.addAll(repository.mailingLists); + } + ccAddresses.addAll(settings.getStrings(Keys.mail.mailingLists)); + + mailing.setCCs(ccAddresses); + } + + protected String readStyle() { + StringBuilder sb = new StringBuilder(); + sb.append("<style>\n"); + sb.append(readResource("email.css")); + sb.append("</style>\n"); + return sb.toString(); + } + + protected String readResource(String resource) { + StringBuilder sb = new StringBuilder(); + InputStream is = null; + try { + is = getClass().getResourceAsStream(resource); + List<String> lines = IOUtils.readLines(is); + for (String line : lines) { + sb.append(line).append('\n'); + } + } catch (IOException e) { + + } finally { + if (is != null) { + try { + is.close(); + } catch (IOException e) { + } + } + } + return sb.toString(); + } +} diff --git a/src/main/java/com/gitblit/tickets/TicketResponsible.java b/src/main/java/com/gitblit/tickets/TicketResponsible.java new file mode 100644 index 00000000..12621c6c --- /dev/null +++ b/src/main/java/com/gitblit/tickets/TicketResponsible.java @@ -0,0 +1,59 @@ +/* + * Copyright 2014 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.tickets; + +import java.io.Serializable; + +import org.parboiled.common.StringUtils; + +import com.gitblit.models.UserModel; + +/** + * A ticket responsible. + * + * @author James Moger + * + */ +public class TicketResponsible implements Serializable, Comparable<TicketResponsible> { + + private static final long serialVersionUID = 1L; + + public final String displayname; + + public final String username; + + public final String email; + + public TicketResponsible(UserModel user) { + this(user.getDisplayName(), user.username, user.emailAddress); + } + + public TicketResponsible(String displayname, String username, String email) { + this.displayname = displayname; + this.username = username; + this.email = email; + } + + @Override + public String toString() { + return displayname + (StringUtils.isEmpty(username) ? "" : (" (" + username + ")")); + } + + @Override + public int compareTo(TicketResponsible o) { + return toString().compareTo(o.toString()); + } +} diff --git a/src/main/java/com/gitblit/tickets/TicketSerializer.java b/src/main/java/com/gitblit/tickets/TicketSerializer.java new file mode 100644 index 00000000..2a71af33 --- /dev/null +++ b/src/main/java/com/gitblit/tickets/TicketSerializer.java @@ -0,0 +1,175 @@ +/* + * Copyright 2013 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.tickets; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; +import java.util.List; + +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Change; +import com.gitblit.models.TicketModel.Score; +import com.gitblit.utils.ArrayUtils; +import com.gitblit.utils.JsonUtils.ExcludeField; +import com.gitblit.utils.JsonUtils.GmtDateTypeAdapter; +import com.google.gson.ExclusionStrategy; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import com.google.gson.JsonSyntaxException; +import com.google.gson.reflect.TypeToken; + +/** + * Serializes and deserializes tickets, change, and journals. + * + * @author James Moger + * + */ +public class TicketSerializer { + + protected static final Type JOURNAL_TYPE = new TypeToken<Collection<Change>>() {}.getType(); + + public static List<Change> deserializeJournal(String json) { + Collection<Change> list = gson().fromJson(json, JOURNAL_TYPE); + return new ArrayList<Change>(list); + } + + public static TicketModel deserializeTicket(String json) { + return gson().fromJson(json, TicketModel.class); + } + + public static TicketLabel deserializeLabel(String json) { + return gson().fromJson(json, TicketLabel.class); + } + + public static TicketMilestone deserializeMilestone(String json) { + return gson().fromJson(json, TicketMilestone.class); + } + + + public static String serializeJournal(List<Change> changes) { + try { + Gson gson = gson(); + return gson.toJson(changes); + } catch (Exception e) { + // won't happen + } + return null; + } + + public static String serialize(TicketModel ticket) { + if (ticket == null) { + return null; + } + try { + Gson gson = gson( + new ExcludeField("com.gitblit.models.TicketModel$Attachment.content"), + new ExcludeField("com.gitblit.models.TicketModel$Attachment.deleted"), + new ExcludeField("com.gitblit.models.TicketModel$Comment.deleted")); + return gson.toJson(ticket); + } catch (Exception e) { + // won't happen + } + return null; + } + + public static String serialize(Change change) { + if (change == null) { + return null; + } + try { + Gson gson = gson( + new ExcludeField("com.gitblit.models.TicketModel$Attachment.content")); + return gson.toJson(change); + } catch (Exception e) { + // won't happen + } + return null; + } + + public static String serialize(TicketLabel label) { + if (label == null) { + return null; + } + try { + Gson gson = gson(); + return gson.toJson(label); + } catch (Exception e) { + // won't happen + } + return null; + } + + public static String serialize(TicketMilestone milestone) { + if (milestone == null) { + return null; + } + try { + Gson gson = gson(); + return gson.toJson(milestone); + } catch (Exception e) { + // won't happen + } + return null; + } + + // build custom gson instance with GMT date serializer/deserializer + // http://code.google.com/p/google-gson/issues/detail?id=281 + public static Gson gson(ExclusionStrategy... strategies) { + GsonBuilder builder = new GsonBuilder(); + builder.registerTypeAdapter(Date.class, new GmtDateTypeAdapter()); + builder.registerTypeAdapter(Score.class, new ScoreTypeAdapter()); + if (!ArrayUtils.isEmpty(strategies)) { + builder.setExclusionStrategies(strategies); + } + return builder.create(); + } + + private static class ScoreTypeAdapter implements JsonSerializer<Score>, JsonDeserializer<Score> { + + private ScoreTypeAdapter() { + } + + @Override + public synchronized JsonElement serialize(Score score, Type type, + JsonSerializationContext jsonSerializationContext) { + return new JsonPrimitive(score.getValue()); + } + + @Override + public synchronized Score deserialize(JsonElement jsonElement, Type type, + JsonDeserializationContext jsonDeserializationContext) { + try { + int value = jsonElement.getAsInt(); + for (Score score : Score.values()) { + if (score.getValue() == value) { + return score; + } + } + return Score.not_reviewed; + } catch (Exception e) { + throw new JsonSyntaxException(jsonElement.getAsString(), e); + } + } + } +} diff --git a/src/main/java/com/gitblit/tickets/commands.md b/src/main/java/com/gitblit/tickets/commands.md new file mode 100644 index 00000000..25c24f4f --- /dev/null +++ b/src/main/java/com/gitblit/tickets/commands.md @@ -0,0 +1,11 @@ +#### To review with Git + +on a detached HEAD... + + git fetch ${repositoryUrl} ${ticketRef} && git checkout FETCH_HEAD + +on a new branch... + + git fetch ${repositoryUrl} ${ticketRef} && git checkout -B ${reviewBranch} FETCH_HEAD + + diff --git a/src/main/java/com/gitblit/tickets/email.css b/src/main/java/com/gitblit/tickets/email.css new file mode 100644 index 00000000..3b815420 --- /dev/null +++ b/src/main/java/com/gitblit/tickets/email.css @@ -0,0 +1,38 @@ +table { + border:1px solid #ddd; + margin: 15px 0px; +} + +th { + font-weight: bold; + border-bottom: 1px solid #ddd; +} + +td, th { + padding: 4px 8px; + vertical-align: top; +} + +a { + color: #2F58A0; +} + +a:hover { + color: #002060; +} + +body { + color: black; +} + +pre { + background-color: rgb(250, 250, 250); + border: 1px solid rgb(221, 221, 221); + border-radius: 4px 4px 4px 4px; + display: block; + font-size: 12px; + line-height: 18px; + margin: 9px 0; + padding: 8.5px; + white-space: pre-wrap; +} diff --git a/src/main/java/com/gitblit/utils/JGitUtils.java b/src/main/java/com/gitblit/utils/JGitUtils.java index 6a6085e7..6f3b0856 100644 --- a/src/main/java/com/gitblit/utils/JGitUtils.java +++ b/src/main/java/com/gitblit/utils/JGitUtils.java @@ -59,6 +59,8 @@ import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.lib.RepositoryCache.FileKey;
import org.eclipse.jgit.lib.StoredConfig;
import org.eclipse.jgit.lib.TreeFormatter;
+import org.eclipse.jgit.merge.MergeStrategy; +import org.eclipse.jgit.merge.RecursiveMerger; import org.eclipse.jgit.revwalk.RevBlob;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevObject;
@@ -82,6 +84,7 @@ import org.eclipse.jgit.util.FS; import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import com.gitblit.GitBlitException; import com.gitblit.models.GitNote;
import com.gitblit.models.PathModel;
import com.gitblit.models.PathModel.PathChangeModel;
@@ -2145,4 +2148,208 @@ public class JGitUtils { }
return false;
}
+ + /** + * Returns true if the commit identified by commitId is an ancestor or the + * the commit identified by tipId. + * + * @param repository + * @param commitId + * @param tipId + * @return true if there is the commit is an ancestor of the tip + */ + public static boolean isMergedInto(Repository repository, String commitId, String tipId) { + try { + return isMergedInto(repository, repository.resolve(commitId), repository.resolve(tipId)); + } catch (Exception e) { + LOGGER.error("Failed to determine isMergedInto", e); + } + return false; + } + + /** + * Returns true if the commit identified by commitId is an ancestor or the + * the commit identified by tipId. + * + * @param repository + * @param commitId + * @param tipId + * @return true if there is the commit is an ancestor of the tip + */ + public static boolean isMergedInto(Repository repository, ObjectId commitId, ObjectId tipCommitId) { + // traverse the revlog looking for a commit chain between the endpoints + RevWalk rw = new RevWalk(repository); + try { + // must re-lookup RevCommits to workaround undocumented RevWalk bug + RevCommit tip = rw.lookupCommit(tipCommitId); + RevCommit commit = rw.lookupCommit(commitId); + return rw.isMergedInto(commit, tip); + } catch (Exception e) { + LOGGER.error("Failed to determine isMergedInto", e); + } finally { + rw.dispose(); + } + return false; + } + + /** + * Returns the merge base of two commits or null if there is no common + * ancestry. + * + * @param repository + * @param commitIdA + * @param commitIdB + * @return the commit id of the merge base or null if there is no common base + */ + public static String getMergeBase(Repository repository, ObjectId commitIdA, ObjectId commitIdB) { + RevWalk rw = new RevWalk(repository); + try { + RevCommit a = rw.lookupCommit(commitIdA); + RevCommit b = rw.lookupCommit(commitIdB); + + rw.setRevFilter(RevFilter.MERGE_BASE); + rw.markStart(a); + rw.markStart(b); + RevCommit mergeBase = rw.next(); + if (mergeBase == null) { + return null; + } + return mergeBase.getName(); + } catch (Exception e) { + LOGGER.error("Failed to determine merge base", e); + } finally { + rw.dispose(); + } + return null; + } + + public static enum MergeStatus { + NOT_MERGEABLE, FAILED, ALREADY_MERGED, MERGEABLE, MERGED; + } + + /** + * Determines if we can cleanly merge one branch into another. Returns true + * if we can merge without conflict, otherwise returns false. + * + * @param repository + * @param src + * @param toBranch + * @return true if we can merge without conflict + */ + public static MergeStatus canMerge(Repository repository, String src, String toBranch) { + RevWalk revWalk = null; + try { + revWalk = new RevWalk(repository); + RevCommit branchTip = revWalk.lookupCommit(repository.resolve(toBranch)); + RevCommit srcTip = revWalk.lookupCommit(repository.resolve(src)); + if (revWalk.isMergedInto(srcTip, branchTip)) { + // already merged + return MergeStatus.ALREADY_MERGED; + } else if (revWalk.isMergedInto(branchTip, srcTip)) { + // fast-forward + return MergeStatus.MERGEABLE; + } + RecursiveMerger merger = (RecursiveMerger) MergeStrategy.RECURSIVE.newMerger(repository, true); + boolean canMerge = merger.merge(branchTip, srcTip); + if (canMerge) { + return MergeStatus.MERGEABLE; + } + } catch (IOException e) { + LOGGER.error("Failed to determine canMerge", e); + } finally { + revWalk.release(); + } + return MergeStatus.NOT_MERGEABLE; + } + + + public static class MergeResult { + public final MergeStatus status; + public final String sha; + + MergeResult(MergeStatus status, String sha) { + this.status = status; + this.sha = sha; + } + } + + /** + * Tries to merge a commit into a branch. If there are conflicts, the merge + * will fail. + * + * @param repository + * @param src + * @param toBranch + * @param committer + * @param message + * @return the merge result + */ + public static MergeResult merge(Repository repository, String src, String toBranch, + PersonIdent committer, String message) { + + if (!toBranch.startsWith(Constants.R_REFS)) { + // branch ref doesn't start with ref, assume this is a branch head + toBranch = Constants.R_HEADS + toBranch; + } + + RevWalk revWalk = null; + try { + revWalk = new RevWalk(repository); + RevCommit branchTip = revWalk.lookupCommit(repository.resolve(toBranch)); + RevCommit srcTip = revWalk.lookupCommit(repository.resolve(src)); + if (revWalk.isMergedInto(srcTip, branchTip)) { + // already merged + return new MergeResult(MergeStatus.ALREADY_MERGED, null); + } + RecursiveMerger merger = (RecursiveMerger) MergeStrategy.RECURSIVE.newMerger(repository, true); + boolean merged = merger.merge(branchTip, srcTip); + if (merged) { + // create a merge commit and a reference to track the merge commit + ObjectId treeId = merger.getResultTreeId(); + ObjectInserter odi = repository.newObjectInserter(); + try { + // Create a commit object + CommitBuilder commitBuilder = new CommitBuilder(); + commitBuilder.setCommitter(committer); + commitBuilder.setAuthor(committer); + commitBuilder.setEncoding(Constants.CHARSET); + if (StringUtils.isEmpty(message)) { + message = MessageFormat.format("merge {0} into {1}", srcTip.getName(), branchTip.getName()); + } + commitBuilder.setMessage(message); + commitBuilder.setParentIds(branchTip.getId(), srcTip.getId()); + commitBuilder.setTreeId(treeId); + + // Insert the merge commit into the repository + ObjectId mergeCommitId = odi.insert(commitBuilder); + odi.flush(); + + // set the merge ref to the merge commit + RevCommit mergeCommit = revWalk.parseCommit(mergeCommitId); + RefUpdate mergeRefUpdate = repository.updateRef(toBranch); + mergeRefUpdate.setNewObjectId(mergeCommitId); + mergeRefUpdate.setRefLogMessage("commit: " + mergeCommit.getShortMessage(), false); + RefUpdate.Result rc = mergeRefUpdate.forceUpdate(); + switch (rc) { + case FAST_FORWARD: + // successful, clean merge + break; + default: + throw new GitBlitException(MessageFormat.format("Unexpected result \"{0}\" when merging commit {1} into {2} in {3}", + rc.name(), srcTip.getName(), branchTip.getName(), repository.getDirectory())); + } + + // return the merge commit id + return new MergeResult(MergeStatus.MERGED, mergeCommitId.getName()); + } finally { + odi.release(); + } + } + } catch (IOException e) { + LOGGER.error("Failed to merge", e); + } finally { + revWalk.release(); + } + return new MergeResult(MergeStatus.FAILED, null); + } }
diff --git a/src/main/java/com/gitblit/utils/JsonUtils.java b/src/main/java/com/gitblit/utils/JsonUtils.java index fdf68e52..be7148cb 100644 --- a/src/main/java/com/gitblit/utils/JsonUtils.java +++ b/src/main/java/com/gitblit/utils/JsonUtils.java @@ -274,10 +274,10 @@ public class JsonUtils { return builder.create();
}
- private static class GmtDateTypeAdapter implements JsonSerializer<Date>, JsonDeserializer<Date> {
+ public static class GmtDateTypeAdapter implements JsonSerializer<Date>, JsonDeserializer<Date> {
private final DateFormat dateFormat;
- private GmtDateTypeAdapter() {
+ public GmtDateTypeAdapter() {
dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US);
dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
}
diff --git a/src/main/java/com/gitblit/utils/MarkdownUtils.java b/src/main/java/com/gitblit/utils/MarkdownUtils.java index 2ce6f566..dcd79f16 100644 --- a/src/main/java/com/gitblit/utils/MarkdownUtils.java +++ b/src/main/java/com/gitblit/utils/MarkdownUtils.java @@ -132,6 +132,10 @@ public class MarkdownUtils { String mentionReplacement = String.format(" **<a href=\"%1s/user/$1\">@$1</a>**", canonicalUrl);
text = text.replaceAll("\\s@([A-Za-z0-9-_]+)", mentionReplacement);
+ // link ticket refs + String ticketReplacement = MessageFormat.format("$1[#$2]({0}/tickets?r={1}&h=$2)$3", canonicalUrl, repositoryName); + text = text.replaceAll("([\\s,]+)#(\\d+)([\\s,:\\.\\n])", ticketReplacement); + // link commit shas
int shaLen = settings.getInteger(Keys.web.shortCommitIdLength, 6);
String commitPattern = MessageFormat.format("\\s([A-Fa-f0-9]'{'{0}'}')([A-Fa-f0-9]'{'{1}'}')", shaLen, 40 - shaLen);
diff --git a/src/main/java/com/gitblit/utils/RefLogUtils.java b/src/main/java/com/gitblit/utils/RefLogUtils.java index d19e892a..4c082d05 100644 --- a/src/main/java/com/gitblit/utils/RefLogUtils.java +++ b/src/main/java/com/gitblit/utils/RefLogUtils.java @@ -213,6 +213,22 @@ public class RefLogUtils { */ public static boolean updateRefLog(UserModel user, Repository repository, Collection<ReceiveCommand> commands) { + + // only track branches and tags + List<ReceiveCommand> filteredCommands = new ArrayList<ReceiveCommand>(); + for (ReceiveCommand cmd : commands) { + if (!cmd.getRefName().startsWith(Constants.R_HEADS) + && !cmd.getRefName().startsWith(Constants.R_TAGS)) { + continue; + } + filteredCommands.add(cmd); + } + + if (filteredCommands.isEmpty()) { + // nothing to log + return true; + } + RefModel reflogBranch = getRefLogBranch(repository); if (reflogBranch == null) { JGitUtils.createOrphanBranch(repository, GB_REFLOG, null); @@ -443,7 +459,15 @@ public class RefLogUtils { Date date = push.getAuthorIdent().getWhen(); RefLogEntry log = new RefLogEntry(repositoryName, date, user); - List<PathChangeModel> changedRefs = JGitUtils.getFilesInCommit(repository, push); + + // only report HEADS and TAGS for now + List<PathChangeModel> changedRefs = new ArrayList<PathChangeModel>(); + for (PathChangeModel refChange : JGitUtils.getFilesInCommit(repository, push)) { + if (refChange.path.startsWith(Constants.R_HEADS) + || refChange.path.startsWith(Constants.R_TAGS)) { + changedRefs.add(refChange); + } + } if (changedRefs.isEmpty()) { // skip empty commits continue; @@ -466,12 +490,16 @@ public class RefLogUtils { // ref deletion continue; } - List<RevCommit> pushedCommits = JGitUtils.getRevLog(repository, oldId, newId); - for (RevCommit pushedCommit : pushedCommits) { - RepositoryCommit repoCommit = log.addCommit(change.path, pushedCommit); - if (repoCommit != null) { - repoCommit.setRefs(allRefs.get(pushedCommit.getId())); + try { + List<RevCommit> pushedCommits = JGitUtils.getRevLog(repository, oldId, newId); + for (RevCommit pushedCommit : pushedCommits) { + RepositoryCommit repoCommit = log.addCommit(change.path, pushedCommit); + if (repoCommit != null) { + repoCommit.setRefs(allRefs.get(pushedCommit.getId())); + } } + } catch (Exception e) { + } } } diff --git a/src/main/java/com/gitblit/wicket/GitBlitWebApp.java b/src/main/java/com/gitblit/wicket/GitBlitWebApp.java index ab5ae2a2..445335ff 100644 --- a/src/main/java/com/gitblit/wicket/GitBlitWebApp.java +++ b/src/main/java/com/gitblit/wicket/GitBlitWebApp.java @@ -38,6 +38,7 @@ import com.gitblit.manager.IProjectManager; import com.gitblit.manager.IRepositoryManager; import com.gitblit.manager.IRuntimeManager; import com.gitblit.manager.IUserManager; +import com.gitblit.tickets.ITicketService; import com.gitblit.utils.StringUtils; import com.gitblit.wicket.pages.ActivityPage; import com.gitblit.wicket.pages.BlamePage; @@ -49,6 +50,8 @@ import com.gitblit.wicket.pages.CommitPage; import com.gitblit.wicket.pages.ComparePage; import com.gitblit.wicket.pages.DocPage; import com.gitblit.wicket.pages.DocsPage; +import com.gitblit.wicket.pages.EditTicketPage; +import com.gitblit.wicket.pages.ExportTicketPage; import com.gitblit.wicket.pages.FederationRegistrationPage; import com.gitblit.wicket.pages.ForkPage; import com.gitblit.wicket.pages.ForksPage; @@ -59,6 +62,7 @@ import com.gitblit.wicket.pages.LogoutPage; import com.gitblit.wicket.pages.LuceneSearchPage; import com.gitblit.wicket.pages.MetricsPage; import com.gitblit.wicket.pages.MyDashboardPage; +import com.gitblit.wicket.pages.NewTicketPage; import com.gitblit.wicket.pages.OverviewPage; import com.gitblit.wicket.pages.PatchPage; import com.gitblit.wicket.pages.ProjectPage; @@ -70,6 +74,7 @@ import com.gitblit.wicket.pages.ReviewProposalPage; import com.gitblit.wicket.pages.SummaryPage; import com.gitblit.wicket.pages.TagPage; import com.gitblit.wicket.pages.TagsPage; +import com.gitblit.wicket.pages.TicketsPage; import com.gitblit.wicket.pages.TreePage; import com.gitblit.wicket.pages.UserPage; import com.gitblit.wicket.pages.UsersPage; @@ -168,6 +173,12 @@ public class GitBlitWebApp extends WebApplication { mount("/users", UsersPage.class); mount("/logout", LogoutPage.class); + // setup ticket urls + mount("/tickets", TicketsPage.class, "r", "h"); + mount("/tickets/new", NewTicketPage.class, "r"); + mount("/tickets/edit", EditTicketPage.class, "r", "h"); + mount("/tickets/export", ExportTicketPage.class, "r", "h"); + // setup the markup document urls mount("/docs", DocsPage.class, "r"); mount("/doc", DocPage.class, "r", "h", "f"); @@ -285,6 +296,10 @@ public class GitBlitWebApp extends WebApplication { return gitblit; } + public ITicketService tickets() { + return gitblit.getTicketService(); + } + public TimeZone getTimezone() { return runtimeManager.getTimezone(); } diff --git a/src/main/java/com/gitblit/wicket/GitBlitWebApp.properties b/src/main/java/com/gitblit/wicket/GitBlitWebApp.properties index 8f3a6aaf..86dd585f 100644 --- a/src/main/java/com/gitblit/wicket/GitBlitWebApp.properties +++ b/src/main/java/com/gitblit/wicket/GitBlitWebApp.properties @@ -18,7 +18,7 @@ gb.object = object gb.ticketId = ticket id gb.ticketAssigned = assigned gb.ticketOpenDate = open date -gb.ticketState = state +gb.ticketStatus = status gb.ticketComments = comments gb.view = view gb.local = local @@ -511,4 +511,141 @@ gb.mirrorOf = mirror of {0} gb.mirrorWarning = this repository is a mirror and can not receive pushes gb.docsWelcome1 = You can use docs to document your repository. gb.docsWelcome2 = Commit a README.md or a HOME.md file to get started. -gb.createReadme = create a README
\ No newline at end of file +gb.createReadme = create a README +gb.responsible = responsible +gb.createdThisTicket = created this ticket +gb.proposedThisChange = proposed this change +gb.uploadedPatchsetN = uploaded patchset {0} +gb.uploadedPatchsetNRevisionN = uploaded patchset {0} revision {1} +gb.mergedPatchset = merged patchset +gb.commented = commented +gb.noDescriptionGiven = no description given +gb.toBranch = to {0} +gb.createdBy = created by +gb.oneParticipant = {0} participant +gb.nParticipants = {0} participants +gb.noComments = no comments +gb.oneComment = {0} comment +gb.nComments = {0} comments +gb.oneAttachment = {0} attachment +gb.nAttachments = {0} attachments +gb.milestone = milestone +gb.compareToMergeBase = compare to merge base +gb.compareToN = compare to {0} +gb.open = open +gb.closed = closed +gb.merged = merged +gb.ticketPatchset = ticket {0}, patchset {1} +gb.patchsetMergeable = This patchset can be automatically merged into {0}. +gb.patchsetMergeableMore = This patchset may also be merged into {0} from the command line. +gb.patchsetAlreadyMerged = This patchset has been merged into {0}. +gb.patchsetNotMergeable = This patchset can not be automatically merged into {0}. +gb.patchsetNotMergeableMore = This patchset must be rebased or manually merged into {0} to resolve conflicts. +gb.patchsetNotApproved = This patchset revision has not been approved for merging into {0}. +gb.patchsetNotApprovedMore = A reviewer must approve this patchset. +gb.patchsetVetoedMore = A reviewer has vetoed this patchset. +gb.write = write +gb.comment = comment +gb.preview = preview +gb.leaveComment = leave a comment... +gb.showHideDetails = show/hide details +gb.acceptNewPatchsets = accept patchsets +gb.acceptNewPatchsetsDescription = accept patchsets pushed to this repository +gb.acceptNewTickets = allow new tickets +gb.acceptNewTicketsDescription = allow creation of bug, enhancement, task ,etc tickets +gb.requireApproval = require approvals +gb.requireApprovalDescription = patchsets must be approved before merge button is enabled +gb.topic = topic +gb.proposalTickets = proposed changes +gb.bugTickets = bugs +gb.enhancementTickets = enhancements +gb.taskTickets = tasks +gb.questionTickets = questions +gb.requestTickets = enhancements & tasks +gb.yourCreatedTickets = created by you +gb.yourWatchedTickets = watched by you +gb.mentionsMeTickets = mentioning you +gb.updatedBy = updated by +gb.sort = sort +gb.sortNewest = newest +gb.sortOldest = oldest +gb.sortMostRecentlyUpdated = recently updated +gb.sortLeastRecentlyUpdated = least recently updated +gb.sortMostComments = most comments +gb.sortLeastComments = least comments +gb.sortMostPatchsetRevisions = most patchset revisions +gb.sortLeastPatchsetRevisions = least patchset revisions +gb.sortMostVotes = most votes +gb.sortLeastVotes = least votes +gb.topicsAndLabels = topics & labels +gb.milestones = milestones +gb.noMilestoneSelected = no milestone selected +gb.notSpecified = not specified +gb.due = due +gb.queries = queries +gb.searchTicketsTooltip = search {0} tickets +gb.searchTickets = search tickets +gb.new = new +gb.newTicket = new ticket +gb.editTicket = edit ticket +gb.ticketsWelcome = You can use tickets to organize your todo list, discuss bugs, and to collaborate on patchsets. +gb.createFirstTicket = create your first ticket +gb.title = title +gb.changedStatus = changed the status +gb.discussion = discussion +gb.updated = updated +gb.proposePatchset = propose a patchset +gb.proposePatchsetNote = You are welcome to propose a patchset for this ticket. +gb.proposeInstructions = To start, craft a patchset and upload it with Git. Gitblit will link your patchset to this ticket by the id. +gb.proposeWith = propose a patchset with {0} +gb.revisionHistory = revision history +gb.merge = merge +gb.action = action +gb.patchset = patchset +gb.all = all +gb.mergeBase = merge base +gb.checkout = checkout +gb.checkoutViaCommandLine = Checkout via command line +gb.checkoutViaCommandLineNote = You can checkout and test these changes locally from your clone of this repository. +gb.checkoutStep1 = Fetch the current patchset \u2014 run this from your project directory +gb.checkoutStep2 = Checkout the patchset to a new branch and review +gb.mergingViaCommandLine = Merging via command line +gb.mergingViaCommandLineNote = If you do not want to use the merge button or an automatic merge cannot be performed, you can perform a manual merge on the command line. +gb.mergeStep1 = Check out a new branch to review the changes \u2014 run this from your project directory +gb.mergeStep2 = Bring in the proposed changes and review +gb.mergeStep3 = Merge the proposed changes and update the server +gb.download = download +gb.ptDescription = the Gitblit patchset tool +gb.ptCheckout = Fetch & checkout the current patchset to a review branch +gb.ptMerge = Fetch & merge the current patchset into your local branch +gb.ptDescription1 = Barnum is a command-line companion for Git that simplifies the syntax for working with Gitblit Tickets and Patchsets. +gb.ptSimplifiedCollaboration = simplified collaboration syntax +gb.ptSimplifiedMerge = simplified merge syntax +gb.ptDescription2 = Barnum requires Python 3 and native Git. It runs on Windows, Linux, and Mac OS X. +gb.stepN = Step {0} +gb.watchers = watchers +gb.votes = votes +gb.vote = vote for this {0} +gb.watch = watch this {0} +gb.removeVote = remove vote +gb.stopWatching = stop watching +gb.watching = watching +gb.comments = comments +gb.addComment = add comment +gb.export = export +gb.oneCommit = one commit +gb.nCommits = {0} commits +gb.addedOneCommit = added 1 commit +gb.addedNCommits = added {0} commits +gb.commitsInPatchsetN = commits in patchset {0} +gb.patchsetN = patchset {0} +gb.reviewedPatchsetRev = reviewed patchset {0} revision {1}: {2} +gb.review = review +gb.reviews = reviews +gb.veto = veto +gb.needsImprovement = needs improvement +gb.looksGood = looks good +gb.approve = approve +gb.hasNotReviewed = has not reviewed +gb.about = about +gb.ticketN = ticket #{0}
\ No newline at end of file diff --git a/src/main/java/com/gitblit/wicket/pages/BasePage.java b/src/main/java/com/gitblit/wicket/pages/BasePage.java index 24ffd813..3e3de535 100644 --- a/src/main/java/com/gitblit/wicket/pages/BasePage.java +++ b/src/main/java/com/gitblit/wicket/pages/BasePage.java @@ -15,6 +15,8 @@ */
package com.gitblit.wicket.pages;
+import java.io.IOException;
+import java.io.InputStream;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Calendar;
@@ -31,6 +33,7 @@ import java.util.regex.Pattern; import javax.servlet.http.HttpServletRequest;
+import org.apache.commons.io.IOUtils;
import org.apache.wicket.Application;
import org.apache.wicket.Page;
import org.apache.wicket.PageParameters;
@@ -460,4 +463,26 @@ public abstract class BasePage extends SessionPage { }
error(message, true);
}
+
+ protected String readResource(String resource) {
+ StringBuilder sb = new StringBuilder();
+ InputStream is = null;
+ try {
+ is = getClass().getResourceAsStream(resource);
+ List<String> lines = IOUtils.readLines(is);
+ for (String line : lines) {
+ sb.append(line).append('\n');
+ }
+ } catch (IOException e) {
+
+ } finally {
+ if (is != null) {
+ try {
+ is.close();
+ } catch (IOException e) {
+ }
+ }
+ }
+ return sb.toString();
+ }
}
diff --git a/src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.html b/src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.html index 781cf29e..da19ca0f 100644 --- a/src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.html +++ b/src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.html @@ -34,15 +34,18 @@ <tr><th><wicket:message key="gb.gcPeriod"></wicket:message></th><td class="edit"><select class="span2" wicket:id="gcPeriod" tabindex="5" /> <span class="help-inline"><wicket:message key="gb.gcPeriodDescription"></wicket:message></span></td></tr>
<tr><th><wicket:message key="gb.gcThreshold"></wicket:message></th><td class="edit"><input class="span1" type="text" wicket:id="gcThreshold" tabindex="6" /> <span class="help-inline"><wicket:message key="gb.gcThresholdDescription"></wicket:message></span></td></tr>
<tr><th colspan="2"><hr/></th></tr>
- <tr><th><wicket:message key="gb.enableIncrementalPushTags"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="useIncrementalPushTags" tabindex="7" /> <span class="help-inline"><wicket:message key="gb.useIncrementalPushTagsDescription"></wicket:message></span></label></td></tr>
- <tr><th><wicket:message key="gb.showRemoteBranches"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="showRemoteBranches" tabindex="8" /> <span class="help-inline"><wicket:message key="gb.showRemoteBranchesDescription"></wicket:message></span></label></td></tr>
- <tr><th><wicket:message key="gb.skipSizeCalculation"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="skipSizeCalculation" tabindex="9" /> <span class="help-inline"><wicket:message key="gb.skipSizeCalculationDescription"></wicket:message></span></label></td></tr>
- <tr><th><wicket:message key="gb.skipSummaryMetrics"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="skipSummaryMetrics" tabindex="10" /> <span class="help-inline"><wicket:message key="gb.skipSummaryMetricsDescription"></wicket:message></span></label></td></tr>
- <tr><th><wicket:message key="gb.maxActivityCommits"></wicket:message></th><td class="edit"><select class="span2" wicket:id="maxActivityCommits" tabindex="11" /> <span class="help-inline"><wicket:message key="gb.maxActivityCommitsDescription"></wicket:message></span></td></tr>
- <tr><th><wicket:message key="gb.metricAuthorExclusions"></wicket:message></th><td class="edit"><input class="span8" type="text" wicket:id="metricAuthorExclusions" size="40" tabindex="12" /></td></tr>
- <tr><th><wicket:message key="gb.commitMessageRenderer"></wicket:message></th><td class="edit"><select class="span2" wicket:id="commitMessageRenderer" tabindex="13" /></td></tr>
+ <tr><th><wicket:message key="gb.acceptNewTickets"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="acceptNewTickets" tabindex="7" /> <span class="help-inline"><wicket:message key="gb.acceptNewTicketsDescription"></wicket:message></span></label></td></tr>
+ <tr><th><wicket:message key="gb.acceptNewPatchsets"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="acceptNewPatchsets" tabindex="8" /> <span class="help-inline"><wicket:message key="gb.acceptNewPatchsetsDescription"></wicket:message></span></label></td></tr>
+ <tr><th><wicket:message key="gb.requireApproval"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="requireApproval" tabindex="9" /> <span class="help-inline"><wicket:message key="gb.requireApprovalDescription"></wicket:message></span></label></td></tr>
+ <tr><th><wicket:message key="gb.enableIncrementalPushTags"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="useIncrementalPushTags" tabindex="10" /> <span class="help-inline"><wicket:message key="gb.useIncrementalPushTagsDescription"></wicket:message></span></label></td></tr>
+ <tr><th><wicket:message key="gb.showRemoteBranches"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="showRemoteBranches" tabindex="11" /> <span class="help-inline"><wicket:message key="gb.showRemoteBranchesDescription"></wicket:message></span></label></td></tr>
+ <tr><th><wicket:message key="gb.skipSizeCalculation"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="skipSizeCalculation" tabindex="12" /> <span class="help-inline"><wicket:message key="gb.skipSizeCalculationDescription"></wicket:message></span></label></td></tr>
+ <tr><th><wicket:message key="gb.skipSummaryMetrics"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="skipSummaryMetrics" tabindex="13" /> <span class="help-inline"><wicket:message key="gb.skipSummaryMetricsDescription"></wicket:message></span></label></td></tr>
+ <tr><th><wicket:message key="gb.maxActivityCommits"></wicket:message></th><td class="edit"><select class="span2" wicket:id="maxActivityCommits" tabindex="14" /> <span class="help-inline"><wicket:message key="gb.maxActivityCommitsDescription"></wicket:message></span></td></tr>
+ <tr><th><wicket:message key="gb.metricAuthorExclusions"></wicket:message></th><td class="edit"><input class="span8" type="text" wicket:id="metricAuthorExclusions" size="40" tabindex="15" /></td></tr>
+ <tr><th><wicket:message key="gb.commitMessageRenderer"></wicket:message></th><td class="edit"><select class="span2" wicket:id="commitMessageRenderer" tabindex="16" /></td></tr>
<tr><th colspan="2"><hr/></th></tr>
- <tr><th><wicket:message key="gb.mailingLists"></wicket:message></th><td class="edit"><input class="span8" type="text" wicket:id="mailingLists" size="40" tabindex="14" /></td></tr>
+ <tr><th><wicket:message key="gb.mailingLists"></wicket:message></th><td class="edit"><input class="span8" type="text" wicket:id="mailingLists" size="40" tabindex="17" /></td></tr>
</tbody>
</table>
</div>
@@ -51,15 +54,15 @@ <div class="tab-pane" id="permissions">
<table class="plain">
<tbody class="settings">
- <tr><th><wicket:message key="gb.owners"></wicket:message></th><td class="edit"><span wicket:id="owners" tabindex="15" /> </td></tr>
+ <tr><th><wicket:message key="gb.owners"></wicket:message></th><td class="edit"><span wicket:id="owners" tabindex="18" /> </td></tr>
<tr><th colspan="2"><hr/></th></tr>
- <tr><th><wicket:message key="gb.accessRestriction"></wicket:message></th><td class="edit"><select class="span4" wicket:id="accessRestriction" tabindex="16" /></td></tr>
+ <tr><th><wicket:message key="gb.accessRestriction"></wicket:message></th><td class="edit"><select class="span4" wicket:id="accessRestriction" tabindex="19" /></td></tr>
<tr><th colspan="2"><hr/></th></tr>
<tr><th><wicket:message key="gb.authorizationControl"></wicket:message></th><td style="padding:2px;"><span class="authorizationControl" wicket:id="authorizationControl"></span></td></tr>
<tr><th colspan="2"><hr/></th></tr>
- <tr><th><wicket:message key="gb.isFrozen"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="isFrozen" tabindex="17" /> <span class="help-inline"><wicket:message key="gb.isFrozenDescription"></wicket:message></span></label></td></tr>
- <tr><th><wicket:message key="gb.allowForks"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="allowForks" tabindex="18" /> <span class="help-inline"><wicket:message key="gb.allowForksDescription"></wicket:message></span></label></td></tr>
- <tr><th><wicket:message key="gb.verifyCommitter"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="verifyCommitter" tabindex="19" /> <span class="help-inline"><wicket:message key="gb.verifyCommitterDescription"></wicket:message></span><br/><span class="help-inline" style="padding-left:10px;"><wicket:message key="gb.verifyCommitterNote"></wicket:message></span></label></td></tr>
+ <tr><th><wicket:message key="gb.isFrozen"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="isFrozen" tabindex="20" /> <span class="help-inline"><wicket:message key="gb.isFrozenDescription"></wicket:message></span></label></td></tr>
+ <tr><th><wicket:message key="gb.allowForks"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="allowForks" tabindex="21" /> <span class="help-inline"><wicket:message key="gb.allowForksDescription"></wicket:message></span></label></td></tr>
+ <tr><th><wicket:message key="gb.verifyCommitter"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="verifyCommitter" tabindex="22" /> <span class="help-inline"><wicket:message key="gb.verifyCommitterDescription"></wicket:message></span><br/><span class="help-inline" style="padding-left:10px;"><wicket:message key="gb.verifyCommitterNote"></wicket:message></span></label></td></tr>
<tr><th colspan="2"><hr/></th></tr>
<tr><th><wicket:message key="gb.userPermissions"></wicket:message></th><td style="padding:2px;"><span wicket:id="users"></span></td></tr>
<tr><th colspan="2"><hr/></th></tr>
@@ -72,7 +75,7 @@ <div class="tab-pane" id="federation">
<table class="plain">
<tbody class="settings">
- <tr><th><wicket:message key="gb.federationStrategy"></wicket:message></th><td class="edit"><select class="span4" wicket:id="federationStrategy" tabindex="20" /></td></tr>
+ <tr><th><wicket:message key="gb.federationStrategy"></wicket:message></th><td class="edit"><select class="span4" wicket:id="federationStrategy" tabindex="23" /></td></tr>
<tr><th><wicket:message key="gb.federationSets"></wicket:message></th><td style="padding:2px;"><span wicket:id="federationSets"></span></td></tr>
</tbody>
</table>
diff --git a/src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.java b/src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.java index c4f480bb..3a5f122f 100644 --- a/src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.java +++ b/src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.java @@ -410,12 +410,12 @@ public class EditRepositoryPage extends RootSubPage { }
// save the repository
- app().repositories().updateRepositoryModel(oldName, repositoryModel, isCreate);
+ app().gitblit().updateRepositoryModel(oldName, repositoryModel, isCreate);
// repository access permissions
if (repositoryModel.accessRestriction.exceeds(AccessRestrictionType.NONE)) {
- app().repositories().setUserAccessPermissions(repositoryModel, repositoryUsers);
- app().repositories().setTeamAccessPermissions(repositoryModel, repositoryTeams);
+ app().gitblit().setUserAccessPermissions(repositoryModel, repositoryUsers);
+ app().gitblit().setTeamAccessPermissions(repositoryModel, repositoryTeams);
}
} catch (GitBlitException e) {
error(e.getMessage());
@@ -466,11 +466,14 @@ public class EditRepositoryPage extends RootSubPage { }
form.add(new DropDownChoice<FederationStrategy>("federationStrategy", federationStrategies,
new FederationTypeRenderer()));
+ form.add(new CheckBox("acceptNewPatchsets"));
+ form.add(new CheckBox("acceptNewTickets")); + form.add(new CheckBox("requireApproval"));
form.add(new CheckBox("useIncrementalPushTags"));
form.add(new CheckBox("showRemoteBranches"));
form.add(new CheckBox("skipSizeCalculation"));
form.add(new CheckBox("skipSummaryMetrics"));
- List<Integer> maxActivityCommits = Arrays.asList(-1, 0, 25, 50, 75, 100, 150, 200, 250, 500 );
+ List<Integer> maxActivityCommits = Arrays.asList(-1, 0, 25, 50, 75, 100, 150, 200, 250, 500);
form.add(new DropDownChoice<Integer>("maxActivityCommits", maxActivityCommits, new MaxActivityCommitsRenderer()));
metricAuthorExclusions = new Model<String>(ArrayUtils.isEmpty(repositoryModel.metricAuthorExclusions) ? ""
diff --git a/src/main/java/com/gitblit/wicket/pages/EditTicketPage.html b/src/main/java/com/gitblit/wicket/pages/EditTicketPage.html new file mode 100644 index 00000000..5d8f6829 --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/EditTicketPage.html @@ -0,0 +1,66 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:wicket="http://wicket.apache.org/dtds.data/wicket-xhtml1.3-strict.dtd"
+ xml:lang="en"
+ lang="en">
+
+<wicket:extend>
+<body onload="document.getElementById('title').focus();">
+
+<div class="container">
+ <!-- page header -->
+ <div class="title" style="font-size: 22px; color: rgb(0, 32, 96);padding: 3px 0px 7px;">
+ <span class="project"><wicket:message key="gb.editTicket"></wicket:message></span>
+ </div>
+
+ <form style="padding-top:5px;" wicket:id="editForm">
+ <div class="row">
+ <div class="span12">
+ <!-- Edit Ticket Table -->
+ <table class="ticket">
+ <tr><th><wicket:message key="gb.title"></wicket:message></th><td class="edit"><input class="input-xxlarge" type="text" wicket:id="title" id="title"></input></td></tr>
+ <tr><th><wicket:message key="gb.topic"></wicket:message></th><td class="edit"><input class="input-large" type="text" wicket:id="topic"></input></td></tr>
+ <tr><th style="vertical-align: top;"><wicket:message key="gb.description"></wicket:message></th><td class="edit">
+ <div style="background-color:#fbfbfb;border:1px solid #ccc;">
+ <ul class="nav nav-pills" style="margin: 2px 5px !important">
+ <li class="active"><a tabindex="-1" href="#edit" data-toggle="tab"><wicket:message key="gb.edit">[edit]</wicket:message></a></li>
+ <li><a tabindex="-1" href="#preview" data-toggle="tab"><wicket:message key="gb.preview">[preview]</wicket:message></a></li>
+ </ul>
+ <div class="tab-content">
+ <div class="tab-pane active" id="edit">
+ <textarea class="input-xxlarge ticket-text-editor" wicket:id="description"></textarea>
+ </div>
+ <div class="tab-pane" id="preview">
+ <div class="preview ticket-text-editor">
+ <div class="markdown input-xxlarge" wicket:id="descriptionPreview"></div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </td></tr>
+ <tr><th><wicket:message key="gb.type"></wicket:message><span style="color:red;">*</span></th><td class="edit"><select class="input-large" wicket:id="type"></select></td></tr>
+ <tr wicket:id="responsible"></tr>
+ <tr wicket:id="milestone"></tr>
+ </table>
+ </div>
+ </div>
+
+ <div class="row">
+ <div class="span12">
+ <div class="form-actions"><input class="btn btn-appmenu" type="submit" value="save" wicket:message="value:gb.save" wicket:id="update" /> <input class="btn" type="submit" value="Cancel" wicket:message="value:gb.cancel" wicket:id="cancel" /></div>
+ </div>
+ </div>
+ </form>
+</div>
+</body>
+
+<wicket:fragment wicket:id="responsibleFragment">
+ <th><wicket:message key="gb.responsible"></wicket:message></th><td class="edit"><select class="input-large" wicket:id="responsible"></select></td>
+</wicket:fragment>
+
+<wicket:fragment wicket:id="milestoneFragment">
+ <th><wicket:message key="gb.milestone"></wicket:message></th><td class="edit"><select class="input-large" wicket:id="milestone"></select></td>
+</wicket:fragment>
+
+</wicket:extend>
+</html>
\ No newline at end of file diff --git a/src/main/java/com/gitblit/wicket/pages/EditTicketPage.java b/src/main/java/com/gitblit/wicket/pages/EditTicketPage.java new file mode 100644 index 00000000..5446dde3 --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/EditTicketPage.java @@ -0,0 +1,290 @@ +/*
+ * Copyright 2014 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.wicket.pages;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+import org.apache.wicket.PageParameters;
+import org.apache.wicket.markup.html.basic.Label;
+import org.apache.wicket.markup.html.form.Button;
+import org.apache.wicket.markup.html.form.DropDownChoice;
+import org.apache.wicket.markup.html.form.Form;
+import org.apache.wicket.markup.html.form.TextField;
+import org.apache.wicket.markup.html.panel.Fragment;
+import org.apache.wicket.model.IModel;
+import org.apache.wicket.model.Model;
+
+import com.gitblit.Constants.AccessPermission;
+import com.gitblit.models.RegistrantAccessPermission;
+import com.gitblit.models.TicketModel;
+import com.gitblit.models.TicketModel.Change;
+import com.gitblit.models.TicketModel.Field;
+import com.gitblit.models.TicketModel.Status;
+import com.gitblit.models.TicketModel.Type;
+import com.gitblit.models.UserModel;
+import com.gitblit.tickets.TicketMilestone;
+import com.gitblit.tickets.TicketNotifier;
+import com.gitblit.tickets.TicketResponsible;
+import com.gitblit.utils.StringUtils;
+import com.gitblit.wicket.GitBlitWebSession;
+import com.gitblit.wicket.WicketUtils;
+import com.gitblit.wicket.panels.MarkdownTextArea;
+
+/**
+ * Page for editing a ticket.
+ *
+ * @author James Moger
+ *
+ */
+public class EditTicketPage extends RepositoryPage {
+
+ static final String NIL = "<nil>";
+
+ static final String ESC_NIL = StringUtils.escapeForHtml(NIL, false);
+
+ private IModel<TicketModel.Type> typeModel;
+
+ private IModel<String> titleModel;
+
+ private MarkdownTextArea descriptionEditor;
+
+ private IModel<String> topicModel;
+
+ private IModel<TicketResponsible> responsibleModel;
+
+ private IModel<TicketMilestone> milestoneModel;
+
+ private Label descriptionPreview;
+
+ public EditTicketPage(PageParameters params) {
+ super(params);
+
+ UserModel currentUser = GitBlitWebSession.get().getUser();
+ if (currentUser == null) {
+ currentUser = UserModel.ANONYMOUS;
+ }
+
+ if (!currentUser.isAuthenticated || !app().tickets().isAcceptingTicketUpdates(getRepositoryModel())) {
+ // tickets prohibited
+ setResponsePage(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
+ }
+
+ long ticketId = 0L;
+ try {
+ String h = WicketUtils.getObject(params);
+ ticketId = Long.parseLong(h);
+ } catch (Exception e) {
+ setResponsePage(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
+ }
+
+ TicketModel ticket = app().tickets().getTicket(getRepositoryModel(), ticketId);
+ if (ticket == null) {
+ setResponsePage(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
+ }
+
+ typeModel = Model.of(ticket.type);
+ titleModel = Model.of(ticket.title);
+ topicModel = Model.of(ticket.topic == null ? "" : ticket.topic);
+ responsibleModel = Model.of();
+ milestoneModel = Model.of();
+
+ setStatelessHint(false);
+ setOutputMarkupId(true);
+
+ Form<Void> form = new Form<Void>("editForm") {
+
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected void onSubmit() {
+ long ticketId = 0L;
+ try {
+ String h = WicketUtils.getObject(getPageParameters());
+ ticketId = Long.parseLong(h);
+ } catch (Exception e) {
+ setResponsePage(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
+ }
+
+ TicketModel ticket = app().tickets().getTicket(getRepositoryModel(), ticketId);
+
+ String createdBy = GitBlitWebSession.get().getUsername();
+ Change change = new Change(createdBy);
+
+ String title = titleModel.getObject();
+ if (!ticket.title.equals(title)) {
+ // title change
+ change.setField(Field.title, title);
+ }
+
+ String description = descriptionEditor.getText();
+ if (!ticket.body.equals(description)) {
+ // description change
+ change.setField(Field.body, description);
+ }
+
+ Type type = typeModel.getObject();
+ if (!ticket.type.equals(type)) {
+ // type change
+ change.setField(Field.type, type);
+ }
+
+ String topic = topicModel.getObject();
+ if ((StringUtils.isEmpty(ticket.topic) && !StringUtils.isEmpty(topic))
+ || (!StringUtils.isEmpty(topic) && !topic.equals(ticket.topic))) {
+ // topic change
+ change.setField(Field.topic, topic);
+ }
+
+ TicketResponsible responsible = responsibleModel == null ? null : responsibleModel.getObject();
+ if (responsible != null && !responsible.username.equals(ticket.responsible)) {
+ // responsible change
+ change.setField(Field.responsible, responsible.username);
+ if (!StringUtils.isEmpty(responsible.username)) {
+ if (!ticket.isWatching(responsible.username)) {
+ change.watch(responsible.username);
+ }
+ }
+ }
+
+ TicketMilestone milestone = milestoneModel == null ? null : milestoneModel.getObject();
+ if (milestone != null && !milestone.name.equals(ticket.milestone)) {
+ // milestone change
+ if (NIL.equals(milestone.name)) {
+ change.setField(Field.milestone, "");
+ } else {
+ change.setField(Field.milestone, milestone.name);
+ }
+ }
+
+ if (change.hasFieldChanges()) {
+ if (!ticket.isWatching(createdBy)) {
+ change.watch(createdBy);
+ }
+ ticket = app().tickets().updateTicket(getRepositoryModel(), ticket.number, change);
+ if (ticket != null) {
+ TicketNotifier notifier = app().tickets().createNotifier();
+ notifier.sendMailing(ticket);
+ setResponsePage(TicketsPage.class, WicketUtils.newObjectParameter(getRepositoryModel().name, "" + ticket.number));
+ } else {
+ // TODO error
+ }
+ } else {
+ // nothing to change?!
+ setResponsePage(TicketsPage.class, WicketUtils.newObjectParameter(getRepositoryModel().name, "" + ticket.number));
+ }
+ }
+ };
+ add(form);
+
+ List<Type> typeChoices;
+ if (ticket.isProposal()) {
+ typeChoices = Arrays.asList(Type.Proposal);
+ } else {
+ typeChoices = Arrays.asList(TicketModel.Type.choices());
+ }
+ form.add(new DropDownChoice<TicketModel.Type>("type", typeModel, typeChoices));
+ form.add(new TextField<String>("title", titleModel));
+ form.add(new TextField<String>("topic", topicModel));
+
+ final IModel<String> markdownPreviewModel = new Model<String>();
+ descriptionPreview = new Label("descriptionPreview", markdownPreviewModel);
+ descriptionPreview.setEscapeModelStrings(false);
+ descriptionPreview.setOutputMarkupId(true);
+ form.add(descriptionPreview);
+
+ descriptionEditor = new MarkdownTextArea("description", markdownPreviewModel, descriptionPreview);
+ descriptionEditor.setRepository(repositoryName);
+ descriptionEditor.setText(ticket.body);
+ form.add(descriptionEditor);
+
+ if (currentUser != null && currentUser.isAuthenticated && currentUser.canPush(getRepositoryModel())) {
+ // responsible
+ Set<String> userlist = new TreeSet<String>(ticket.getParticipants());
+
+ for (RegistrantAccessPermission rp : app().repositories().getUserAccessPermissions(getRepositoryModel())) {
+ if (rp.permission.atLeast(AccessPermission.PUSH) && !rp.isTeam()) {
+ userlist.add(rp.registrant);
+ }
+ }
+
+ List<TicketResponsible> responsibles = new ArrayList<TicketResponsible>();
+ for (String username : userlist) {
+ UserModel user = app().users().getUserModel(username);
+ if (user != null) {
+ TicketResponsible responsible = new TicketResponsible(user);
+ responsibles.add(responsible);
+ if (user.username.equals(ticket.responsible)) {
+ responsibleModel.setObject(responsible);
+ }
+ }
+ }
+ Collections.sort(responsibles);
+ responsibles.add(new TicketResponsible(NIL, "", ""));
+ Fragment responsible = new Fragment("responsible", "responsibleFragment", this);
+ responsible.add(new DropDownChoice<TicketResponsible>("responsible", responsibleModel, responsibles));
+ form.add(responsible.setVisible(!responsibles.isEmpty()));
+
+ // milestone
+ List<TicketMilestone> milestones = app().tickets().getMilestones(getRepositoryModel(), Status.Open);
+ for (TicketMilestone milestone : milestones) {
+ if (milestone.name.equals(ticket.milestone)) {
+ milestoneModel.setObject(milestone);
+ break;
+ }
+ }
+ if (!milestones.isEmpty()) {
+ milestones.add(new TicketMilestone(NIL));
+ }
+
+ Fragment milestone = new Fragment("milestone", "milestoneFragment", this);
+
+ milestone.add(new DropDownChoice<TicketMilestone>("milestone", milestoneModel, milestones));
+ form.add(milestone.setVisible(!milestones.isEmpty()));
+ } else {
+ // user does not have permission to assign milestone or responsible
+ form.add(new Label("responsible").setVisible(false));
+ form.add(new Label("milestone").setVisible(false));
+ }
+
+ form.add(new Button("update"));
+ Button cancel = new Button("cancel") {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void onSubmit() {
+ setResponsePage(TicketsPage.class, getPageParameters());
+ }
+ };
+ cancel.setDefaultFormProcessing(false);
+ form.add(cancel);
+
+ }
+
+ @Override
+ protected String getPageName() {
+ return getString("gb.editTicket");
+ }
+
+ @Override
+ protected Class<? extends BasePage> getRepoNavPageClass() {
+ return TicketsPage.class;
+ }
+}
diff --git a/src/main/java/com/gitblit/wicket/pages/ExportTicketPage.java b/src/main/java/com/gitblit/wicket/pages/ExportTicketPage.java new file mode 100644 index 00000000..57f61f78 --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/ExportTicketPage.java @@ -0,0 +1,82 @@ +/*
+ * Copyright 2013 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.wicket.pages;
+
+import org.apache.wicket.IRequestTarget;
+import org.apache.wicket.PageParameters;
+import org.apache.wicket.RequestCycle;
+import org.apache.wicket.protocol.http.WebResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.gitblit.models.RepositoryModel;
+import com.gitblit.models.TicketModel;
+import com.gitblit.tickets.TicketSerializer;
+import com.gitblit.utils.StringUtils;
+import com.gitblit.wicket.WicketUtils;
+
+public class ExportTicketPage extends SessionPage {
+
+ private final Logger logger = LoggerFactory.getLogger(getClass().getSimpleName());
+
+ String contentType;
+
+ public ExportTicketPage(final PageParameters params) {
+ super(params);
+
+ if (!params.containsKey("r")) {
+ error(getString("gb.repositoryNotSpecified"));
+ redirectToInterceptPage(new RepositoriesPage());
+ }
+
+ getRequestCycle().setRequestTarget(new IRequestTarget() {
+ @Override
+ public void detach(RequestCycle requestCycle) {
+ }
+
+ @Override
+ public void respond(RequestCycle requestCycle) {
+ WebResponse response = (WebResponse) requestCycle.getResponse();
+
+ final String repositoryName = WicketUtils.getRepositoryName(params);
+ RepositoryModel repository = app().repositories().getRepositoryModel(repositoryName);
+ String objectId = WicketUtils.getObject(params).toLowerCase();
+ if (objectId.endsWith(".json")) {
+ objectId = objectId.substring(0, objectId.length() - ".json".length());
+ }
+ long id = Long.parseLong(objectId);
+ TicketModel ticket = app().tickets().getTicket(repository, id);
+
+ String content = TicketSerializer.serialize(ticket);
+ contentType = "application/json; charset=UTF-8";
+ response.setContentType(contentType);
+ try {
+ response.getOutputStream().write(content.getBytes("UTF-8"));
+ } catch (Exception e) {
+ logger.error("Failed to write text response", e);
+ }
+ }
+ });
+ }
+
+ @Override
+ protected void setHeaders(WebResponse response) {
+ super.setHeaders(response);
+ if (!StringUtils.isEmpty(contentType)) {
+ response.setContentType(contentType);
+ }
+ }
+}
diff --git a/src/main/java/com/gitblit/wicket/pages/NewTicketPage.html b/src/main/java/com/gitblit/wicket/pages/NewTicketPage.html new file mode 100644 index 00000000..71570df4 --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/NewTicketPage.html @@ -0,0 +1,66 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:wicket="http://wicket.apache.org/dtds.data/wicket-xhtml1.3-strict.dtd"
+ xml:lang="en"
+ lang="en">
+
+<wicket:extend>
+<body onload="document.getElementById('title').focus();">
+
+<div class="container">
+ <!-- page header -->
+ <div class="title" style="font-size: 22px; color: rgb(0, 32, 96);padding: 3px 0px 7px;">
+ <span class="project"><wicket:message key="gb.newTicket"></wicket:message></span>
+ </div>
+
+ <form style="padding-top:5px;" wicket:id="editForm">
+ <div class="row">
+ <div class="span12">
+ <!-- New Ticket Table -->
+ <table class="ticket">
+ <tr><th><wicket:message key="gb.title"></wicket:message></th><td class="edit"><input class="input-xxlarge" type="text" wicket:id="title" id="title"></input></td></tr>
+ <tr><th><wicket:message key="gb.topic"></wicket:message></th><td class="edit"><input class="input-large" type="text" wicket:id="topic"></input></td></tr>
+ <tr><th style="vertical-align: top;"><wicket:message key="gb.description"></wicket:message></th><td class="edit">
+ <div style="background-color:#fbfbfb;border:1px solid #ccc;">
+ <ul class="nav nav-pills" style="margin: 2px 5px !important">
+ <li class="active"><a tabindex="-1" href="#edit" data-toggle="tab"><wicket:message key="gb.edit">[edit]</wicket:message></a></li>
+ <li><a tabindex="-1" href="#preview" data-toggle="tab"><wicket:message key="gb.preview">[preview]</wicket:message></a></li>
+ </ul>
+ <div class="tab-content">
+ <div class="tab-pane active" id="edit">
+ <textarea class="input-xxlarge ticket-text-editor" wicket:id="description"></textarea>
+ </div>
+ <div class="tab-pane" id="preview">
+ <div class="preview ticket-text-editor">
+ <div class="markdown input-xxlarge" wicket:id="descriptionPreview"></div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </td></tr>
+ <tr><th><wicket:message key="gb.type"></wicket:message><span style="color:red;">*</span></th><td class="edit"><select class="input-large" wicket:id="type"></select></td></tr>
+ <tr wicket:id="responsible"></tr>
+ <tr wicket:id="milestone"></tr>
+ </table>
+ </div>
+ </div>
+
+ <div class="row">
+ <div class="span12">
+ <div class="form-actions"><input class="btn btn-appmenu" type="submit" value="Create" wicket:message="value:gb.create" wicket:id="create" /> <input class="btn" type="submit" value="Cancel" wicket:message="value:gb.cancel" wicket:id="cancel" /></div>
+ </div>
+ </div>
+ </form>
+</div>
+</body>
+
+<wicket:fragment wicket:id="responsibleFragment">
+ <th><wicket:message key="gb.responsible"></wicket:message></th><td class="edit"><select class="input-large" wicket:id="responsible"></select></td>
+</wicket:fragment>
+
+<wicket:fragment wicket:id="milestoneFragment">
+ <th><wicket:message key="gb.milestone"></wicket:message></th><td class="edit"><select class="input-large" wicket:id="milestone"></select></td>
+</wicket:fragment>
+
+</wicket:extend>
+</html>
\ No newline at end of file diff --git a/src/main/java/com/gitblit/wicket/pages/NewTicketPage.java b/src/main/java/com/gitblit/wicket/pages/NewTicketPage.java new file mode 100644 index 00000000..17ad1d1b --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/NewTicketPage.java @@ -0,0 +1,202 @@ +/*
+ * Copyright 2014 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.wicket.pages;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.wicket.PageParameters;
+import org.apache.wicket.markup.html.basic.Label;
+import org.apache.wicket.markup.html.form.Button;
+import org.apache.wicket.markup.html.form.DropDownChoice;
+import org.apache.wicket.markup.html.form.Form;
+import org.apache.wicket.markup.html.form.TextField;
+import org.apache.wicket.markup.html.panel.Fragment;
+import org.apache.wicket.model.IModel;
+import org.apache.wicket.model.Model;
+
+import com.gitblit.Constants.AccessPermission;
+import com.gitblit.models.RegistrantAccessPermission;
+import com.gitblit.models.TicketModel;
+import com.gitblit.models.TicketModel.Change;
+import com.gitblit.models.TicketModel.Field;
+import com.gitblit.models.TicketModel.Status;
+import com.gitblit.models.UserModel;
+import com.gitblit.tickets.TicketMilestone;
+import com.gitblit.tickets.TicketNotifier;
+import com.gitblit.tickets.TicketResponsible;
+import com.gitblit.utils.StringUtils;
+import com.gitblit.wicket.GitBlitWebSession;
+import com.gitblit.wicket.WicketUtils;
+import com.gitblit.wicket.panels.MarkdownTextArea;
+
+/**
+ * Page for creating a new ticket.
+ *
+ * @author James Moger
+ *
+ */
+public class NewTicketPage extends RepositoryPage {
+
+ private IModel<TicketModel.Type> typeModel;
+
+ private IModel<String> titleModel;
+
+ private MarkdownTextArea descriptionEditor;
+
+ private IModel<String> topicModel;
+
+ private IModel<TicketResponsible> responsibleModel;
+
+ private IModel<TicketMilestone> milestoneModel;
+
+ private Label descriptionPreview;
+
+ public NewTicketPage(PageParameters params) {
+ super(params);
+
+ UserModel currentUser = GitBlitWebSession.get().getUser();
+ if (currentUser == null) {
+ currentUser = UserModel.ANONYMOUS;
+ }
+
+ if (!currentUser.isAuthenticated || !app().tickets().isAcceptingNewTickets(getRepositoryModel())) {
+ // tickets prohibited
+ setResponsePage(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
+ }
+
+ typeModel = Model.of(TicketModel.Type.defaultType);
+ titleModel = Model.of();
+ topicModel = Model.of();
+ responsibleModel = Model.of();
+ milestoneModel = Model.of();
+
+ setStatelessHint(false);
+ setOutputMarkupId(true);
+
+ Form<Void> form = new Form<Void>("editForm") {
+
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected void onSubmit() {
+ String createdBy = GitBlitWebSession.get().getUsername();
+ Change change = new Change(createdBy);
+ change.setField(Field.title, titleModel.getObject());
+ change.setField(Field.body, descriptionEditor.getText());
+ String topic = topicModel.getObject();
+ if (!StringUtils.isEmpty(topic)) {
+ change.setField(Field.topic, topic);
+ }
+
+ // type
+ TicketModel.Type type = TicketModel.Type.defaultType;
+ if (typeModel.getObject() != null) {
+ type = typeModel.getObject();
+ }
+ change.setField(Field.type, type);
+
+ // responsible
+ TicketResponsible responsible = responsibleModel == null ? null : responsibleModel.getObject();
+ if (responsible != null) {
+ change.setField(Field.responsible, responsible.username);
+ }
+
+ // milestone
+ TicketMilestone milestone = milestoneModel == null ? null : milestoneModel.getObject();
+ if (milestone != null) {
+ change.setField(Field.milestone, milestone.name);
+ }
+
+ TicketModel ticket = app().tickets().createTicket(getRepositoryModel(), 0L, change);
+ if (ticket != null) {
+ TicketNotifier notifier = app().tickets().createNotifier();
+ notifier.sendMailing(ticket);
+ setResponsePage(TicketsPage.class, WicketUtils.newObjectParameter(getRepositoryModel().name, "" + ticket.number));
+ } else {
+ // TODO error
+ }
+ }
+ };
+ add(form);
+
+ form.add(new DropDownChoice<TicketModel.Type>("type", typeModel, Arrays.asList(TicketModel.Type.choices())));
+ form.add(new TextField<String>("title", titleModel));
+ form.add(new TextField<String>("topic", topicModel));
+
+ final IModel<String> markdownPreviewModel = new Model<String>();
+ descriptionPreview = new Label("descriptionPreview", markdownPreviewModel);
+ descriptionPreview.setEscapeModelStrings(false);
+ descriptionPreview.setOutputMarkupId(true);
+ form.add(descriptionPreview);
+
+ descriptionEditor = new MarkdownTextArea("description", markdownPreviewModel, descriptionPreview);
+ descriptionEditor.setRepository(repositoryName);
+ form.add(descriptionEditor);
+
+ if (currentUser != null && currentUser.isAuthenticated && currentUser.canPush(getRepositoryModel())) {
+ // responsible
+ List<TicketResponsible> responsibles = new ArrayList<TicketResponsible>();
+ for (RegistrantAccessPermission rp : app().repositories().getUserAccessPermissions(getRepositoryModel())) {
+ if (rp.permission.atLeast(AccessPermission.PUSH) && !rp.isTeam()) {
+ UserModel user = app().users().getUserModel(rp.registrant);
+ if (user != null) {
+ responsibles.add(new TicketResponsible(user));
+ }
+ }
+ }
+ Collections.sort(responsibles);
+ Fragment responsible = new Fragment("responsible", "responsibleFragment", this);
+ responsible.add(new DropDownChoice<TicketResponsible>("responsible", responsibleModel, responsibles));
+ form.add(responsible.setVisible(!responsibles.isEmpty()));
+
+ // milestone
+ List<TicketMilestone> milestones = app().tickets().getMilestones(getRepositoryModel(), Status.Open);
+ Fragment milestone = new Fragment("milestone", "milestoneFragment", this);
+ milestone.add(new DropDownChoice<TicketMilestone>("milestone", milestoneModel, milestones));
+ form.add(milestone.setVisible(!milestones.isEmpty()));
+ } else {
+ // user does not have permission to assign milestone or responsible
+ form.add(new Label("responsible").setVisible(false));
+ form.add(new Label("milestone").setVisible(false));
+ }
+
+ form.add(new Button("create"));
+ Button cancel = new Button("cancel") {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void onSubmit() {
+ setResponsePage(TicketsPage.class, getPageParameters());
+ }
+ };
+ cancel.setDefaultFormProcessing(false);
+ form.add(cancel);
+
+ }
+
+ @Override
+ protected String getPageName() {
+ return getString("gb.newTicket");
+ }
+
+ @Override
+ protected Class<? extends BasePage> getRepoNavPageClass() {
+ return TicketsPage.class;
+ }
+}
diff --git a/src/main/java/com/gitblit/wicket/pages/NoTicketsPage.html b/src/main/java/com/gitblit/wicket/pages/NoTicketsPage.html new file mode 100644 index 00000000..3eb56351 --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/NoTicketsPage.html @@ -0,0 +1,21 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:wicket="http://wicket.apache.org/dtds.data/wicket-xhtml1.3-strict.dtd"
+ xml:lang="en"
+ lang="en">
+
+<wicket:extend>
+ <!-- No tickets -->
+ <div class="featureWelcome">
+ <div class="row">
+ <div class="icon span2"><i class="fa fa-ticket"></i></div>
+ <div class="span9">
+ <h1><wicket:message key="gb.tickets"></wicket:message></h1>
+ <wicket:message key="gb.ticketsWelcome"></wicket:message>
+ <p></p>
+ <a wicket:id="newticket" class="btn btn-appmenu"><wicket:message key="gb.createFirstTicket"></wicket:message></a>
+ </div>
+ </div>
+ </div>
+</wicket:extend>
+</html>
\ No newline at end of file diff --git a/src/main/java/com/gitblit/wicket/pages/NoTicketsPage.java b/src/main/java/com/gitblit/wicket/pages/NoTicketsPage.java new file mode 100644 index 00000000..8e98a00f --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/NoTicketsPage.java @@ -0,0 +1,44 @@ +/*
+ * Copyright 2013 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.wicket.pages;
+
+import org.apache.wicket.PageParameters;
+import org.apache.wicket.markup.html.link.BookmarkablePageLink;
+
+import com.gitblit.models.UserModel;
+import com.gitblit.wicket.GitBlitWebSession;
+import com.gitblit.wicket.WicketUtils;
+
+public class NoTicketsPage extends RepositoryPage {
+
+ public NoTicketsPage(PageParameters params) {
+ super(params);
+
+ UserModel user = GitBlitWebSession.get().getUser();
+ boolean isAuthenticated = user != null && user.isAuthenticated;
+ add(new BookmarkablePageLink<Void>("newticket", NewTicketPage.class, WicketUtils.newRepositoryParameter(repositoryName)).setVisible(isAuthenticated));
+ }
+
+ @Override
+ protected String getPageName() {
+ return getString("gb.tickets");
+ }
+
+ @Override
+ protected Class<? extends BasePage> getRepoNavPageClass() {
+ return TicketsPage.class;
+ }
+}
diff --git a/src/main/java/com/gitblit/wicket/pages/RepositoryPage.html b/src/main/java/com/gitblit/wicket/pages/RepositoryPage.html index 0acc6dbc..cb4f1b67 100644 --- a/src/main/java/com/gitblit/wicket/pages/RepositoryPage.html +++ b/src/main/java/com/gitblit/wicket/pages/RepositoryPage.html @@ -38,6 +38,7 @@ <div>
<div class="hidden-phone btn-group pull-right" style="margin-top:5px;">
<!-- future spot for other repo buttons -->
+ <a class="btn" wicket:id="newTicketLink"></a>
<a class="btn" wicket:id="starLink"></a>
<a class="btn" wicket:id="unstarLink"></a>
<a class="btn" wicket:id="myForkLink"><img style="border:0px;vertical-align:middle;" src="fork-black_16x16.png"></img> <wicket:message key="gb.myFork"></wicket:message></a>
diff --git a/src/main/java/com/gitblit/wicket/pages/RepositoryPage.java b/src/main/java/com/gitblit/wicket/pages/RepositoryPage.java index 079cb2e9..86df4565 100644 --- a/src/main/java/com/gitblit/wicket/pages/RepositoryPage.java +++ b/src/main/java/com/gitblit/wicket/pages/RepositoryPage.java @@ -56,6 +56,7 @@ import com.gitblit.models.UserModel; import com.gitblit.models.UserRepositoryPreferences;
import com.gitblit.servlet.PagesServlet;
import com.gitblit.servlet.SyndicationServlet;
+import com.gitblit.tickets.TicketIndexer.Lucene;
import com.gitblit.utils.ArrayUtils;
import com.gitblit.utils.DeepCopier;
import com.gitblit.utils.JGitUtils;
@@ -95,7 +96,7 @@ public abstract class RepositoryPage extends RootPage { public RepositoryPage(PageParameters params) {
super(params);
repositoryName = WicketUtils.getRepositoryName(params);
- String root =StringUtils.getFirstPathElement(repositoryName);
+ String root = StringUtils.getFirstPathElement(repositoryName);
if (StringUtils.isEmpty(root)) {
projectName = app().settings().getString(Keys.web.repositoryRootGroupName, "main");
} else {
@@ -200,11 +201,18 @@ public abstract class RepositoryPage extends RootPage { }
pages.put("commits", new PageRegistration("gb.commits", LogPage.class, params));
pages.put("tree", new PageRegistration("gb.tree", TreePage.class, params));
+ if (app().tickets().isReady() && (app().tickets().isAcceptingNewTickets(getRepositoryModel()) || app().tickets().hasTickets(getRepositoryModel()))) {
+ PageParameters tParams = new PageParameters(params);
+ for (String state : TicketsPage.openStatii) {
+ tParams.add(Lucene.status.name(), state);
+ }
+ pages.put("tickets", new PageRegistration("gb.tickets", TicketsPage.class, tParams)); + } pages.put("docs", new PageRegistration("gb.docs", DocsPage.class, params, true));
- pages.put("compare", new PageRegistration("gb.compare", ComparePage.class, params, true));
if (app().settings().getBoolean(Keys.web.allowForking, true)) {
pages.put("forks", new PageRegistration("gb.forks", ForksPage.class, params, true));
}
+ pages.put("compare", new PageRegistration("gb.compare", ComparePage.class, params, true)); // conditional links
// per-repository extra page links
@@ -288,6 +296,14 @@ public abstract class RepositoryPage extends RootPage { }
}
+ // new ticket button
+ if (user.isAuthenticated && app().tickets().isAcceptingNewTickets(getRepositoryModel())) {
+ String newTicketUrl = getRequestCycle().urlFor(NewTicketPage.class, WicketUtils.newRepositoryParameter(repositoryName)).toString();
+ addToolbarButton("newTicketLink", "fa fa-ticket", getString("gb.new"), newTicketUrl);
+ } else {
+ add(new Label("newTicketLink").setVisible(false));
+ }
+
// (un)star link allows a user to star a repository
if (user.isAuthenticated) {
PageParameters starParams = DeepCopier.copy(getPageParameters());
diff --git a/src/main/java/com/gitblit/wicket/pages/TicketBasePage.java b/src/main/java/com/gitblit/wicket/pages/TicketBasePage.java new file mode 100644 index 00000000..3736cddf --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/TicketBasePage.java @@ -0,0 +1,124 @@ +/*
+ * Copyright 2013 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.wicket.pages;
+
+import org.apache.wicket.PageParameters;
+import org.apache.wicket.markup.html.basic.Label;
+
+import com.gitblit.models.TicketModel;
+import com.gitblit.models.TicketModel.Status;
+import com.gitblit.models.TicketModel.Type;
+import com.gitblit.wicket.WicketUtils;
+
+public abstract class TicketBasePage extends RepositoryPage {
+
+ public TicketBasePage(PageParameters params) {
+ super(params);
+ }
+
+ protected Label getStateIcon(String wicketId, TicketModel ticket) {
+ return getStateIcon(wicketId, ticket.type, ticket.status);
+ }
+
+ protected Label getStateIcon(String wicketId, Type type, Status state) {
+ Label label = new Label(wicketId);
+ if (type == null) {
+ type = Type.defaultType;
+ }
+ switch (type) {
+ case Proposal:
+ WicketUtils.setCssClass(label, "fa fa-code-fork");
+ break;
+ case Bug:
+ WicketUtils.setCssClass(label, "fa fa-bug");
+ break;
+ case Enhancement:
+ WicketUtils.setCssClass(label, "fa fa-magic");
+ break;
+ case Question:
+ WicketUtils.setCssClass(label, "fa fa-question");
+ break;
+ default:
+ // standard ticket
+ WicketUtils.setCssClass(label, "fa fa-ticket");
+ }
+ WicketUtils.setHtmlTooltip(label, getTypeState(type, state));
+ return label;
+ }
+
+ protected String getTypeState(Type type, Status state) {
+ return state.toString() + " " + type.toString();
+ }
+
+ protected String getLozengeClass(Status status, boolean subtle) {
+ if (status == null) {
+ status = Status.New;
+ }
+ String css = "";
+ switch (status) {
+ case Declined:
+ case Duplicate:
+ case Invalid:
+ case Wontfix:
+ css = "aui-lozenge-error";
+ break;
+ case Fixed:
+ case Merged:
+ case Resolved:
+ css = "aui-lozenge-success";
+ break;
+ case New:
+ css = "aui-lozenge-complete";
+ break;
+ case On_Hold:
+ css = "aui-lozenge-current";
+ break;
+ default:
+ css = "";
+ break;
+ }
+
+ return "aui-lozenge" + (subtle ? " aui-lozenge-subtle": "") + (css.isEmpty() ? "" : " ") + css;
+ }
+
+ protected String getStatusClass(Status status) {
+ String css = "";
+ switch (status) {
+ case Declined:
+ case Duplicate:
+ case Invalid:
+ case Wontfix:
+ css = "resolution-error";
+ break;
+ case Fixed:
+ case Merged:
+ case Resolved:
+ css = "resolution-success";
+ break;
+ case New:
+ css = "resolution-complete";
+ break;
+ case On_Hold:
+ css = "resolution-current";
+ break;
+ default:
+ css = "";
+ break;
+ }
+
+ return "resolution" + (css.isEmpty() ? "" : " ") + css;
+ }
+}
diff --git a/src/main/java/com/gitblit/wicket/pages/TicketPage.html b/src/main/java/com/gitblit/wicket/pages/TicketPage.html new file mode 100644 index 00000000..2e0288a5 --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/TicketPage.html @@ -0,0 +1,577 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:wicket="http://wicket.apache.org/dtds.data/wicket-xhtml1.3-strict.dtd"
+ xml:lang="en"
+ lang="en">
+
+<body>
+<wicket:extend>
+
+<!-- HEADER -->
+<div style="padding: 10px 0px 15px;">
+ <div style="display:inline-block;" class="ticket-title"><span wicket:id="ticketTitle">[ticket title]</span></div>
+ <a style="padding-left:10px;" class="ticket-number" wicket:id="ticketNumber"></a>
+ <div style="display:inline-block;padding: 0px 10px;vertical-align:top;"><span wicket:id="headerStatus"></span></div>
+ <div class="hidden-phone hidden-tablet pull-right"><div wicket:id="diffstat"></div></div>
+</div>
+
+<!-- TAB NAMES -->
+<ul class="nav nav-tabs">
+ <li class="active"><a data-toggle="tab" href="#discussion">
+ <i style="color:#888;"class="fa fa-comments"></i> <span class="hidden-phone"><wicket:message key="gb.discussion"></wicket:message></span> <span class="lwbadge" wicket:id="commentCount"></span></a>
+ </li>
+ <li><a data-toggle="tab" href="#commits">
+ <i style="color:#888;"class="fa fa-code"></i> <span class="hidden-phone"><wicket:message key="gb.commits"></wicket:message></span> <span class="lwbadge" wicket:id="commitCount"></span></a>
+ </li>
+ <li><a data-toggle="tab" href="#activity">
+ <i style="color:#888;"class="fa fa-clock-o"></i> <span class="hidden-phone"><wicket:message key="gb.activity"></wicket:message></span></a>
+ </li>
+</ul>
+
+<!-- TABS -->
+<div class="tab-content">
+
+ <!-- DISCUSSION TAB -->
+ <div class="tab-pane active" id="discussion">
+ <div class="row">
+
+ <!-- LEFT SIDE -->
+ <div class="span8">
+ <div class="ticket-meta-middle">
+ <!-- creator -->
+ <span class="attribution-emphasize" wicket:id="whoCreated">[someone]</span><span wicket:id="creationMessage" class="attribution-text" style="padding: 0px 3px;">[created this ticket]</span> <span class="attribution-emphasize" wicket:id="whenCreated">[when created]</span>
+ </div>
+ <div class="ticket-meta-bottom"">
+ <div class="ticket-text markdown" wicket:id="ticketDescription">[description]</div>
+ </div>
+
+ <!-- COMMENTS and STATUS CHANGES (DISCUSSIONS TAB) -->
+ <div wicket:id="discussion"></div>
+
+
+ <!-- ADD COMMENT (DISCUSSIONS TAB) -->
+ <div id="addcomment" wicket:id="newComment"></div>
+ </div>
+
+ <!-- RIGHT SIDE -->
+ <div class="span4 hidden-phone">
+ <div class="status-display" style="padding-bottom: 5px;">
+ <div wicket:id="ticketStatus" style="display:block;padding: 5px 10px 10px;">[ticket status]</div>
+ </div>
+ <div wicket:id="labels" style="border-top: 1px solid #ccc;padding: 5px 0px;">
+ <span class="label ticketLabel" wicket:id="label">[label]</span>
+ </div>
+
+ <div wicket:id="controls"></div>
+
+ <div style="border: 1px solid #ccc;padding: 10px;margin: 5px 0px;">
+ <table class="summary" style="width: 100%">
+ <tr><th><wicket:message key="gb.type"></wicket:message></th><td><span wicket:id="ticketType">[type]</span></td></tr>
+ <tr><th><wicket:message key="gb.topic"></wicket:message></th><td><span wicket:id="ticketTopic">[topic]</span></td></tr>
+ <tr><th><wicket:message key="gb.responsible"></wicket:message></th><td><span wicket:id="responsible">[responsible]</span></td></tr>
+ <tr><th><wicket:message key="gb.milestone"></wicket:message></th><td><span wicket:id="milestone">[milestone]</span></td></tr>
+ <tr><th><wicket:message key="gb.votes"></wicket:message></th><td><span wicket:id="votes" class="badge">1</span> <a style="padding-left:5px" wicket:id="voteLink" href="#">vote</a></td></tr>
+ <tr><th><wicket:message key="gb.watchers"></wicket:message></th><td><span wicket:id="watchers" class="badge">1</span> <a style="padding-left:5px" wicket:id="watchLink" href="#">watch</a></td></tr>
+ <tr><th><wicket:message key="gb.export"></wicket:message></th><td><a rel="nofollow" target="_blank" wicket:id="exportJson"></a></td></tr>
+
+ </table>
+ </div>
+
+ <div>
+ <span class="attribution-text" wicket:id="participantsLabel"></span>
+ <span wicket:id="participants"><span style="padding: 0px 2px;" wicket:id="participant"></span></span>
+ </div>
+ </div>
+ </div>
+
+ </div>
+
+
+ <!-- COMMITS TAB -->
+ <div class="tab-pane" id="commits">
+ <div wicket:id="patchset"></div>
+ </div>
+
+
+ <!-- ACTIVITY TAB -->
+ <div class="tab-pane" id="activity">
+ <div wicket:id="activity"></div>
+ </div>
+
+</div> <!-- END TABS -->
+
+
+<!-- BARNUM DOWNLOAD MODAL -->
+<div id="ptModal" class="modal hide fade" tabindex="-1" role="dialog" aria-labelledby="ptModalLabel" aria-hidden="true">
+ <div class="modal-header">
+ <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
+ <h3 id="ptModalLabel"><img src="barnum_32x32.png"></img> Barnum <small><wicket:message key="gb.ptDescription"></wicket:message></small></h3>
+ </div>
+ <div class="modal-body">
+ <p><wicket:message key="gb.ptDescription1"></wicket:message></p>
+
+ <h4><wicket:message key="gb.ptSimplifiedCollaboration"></wicket:message></h4>
+ <pre class="gitcommand">
+pt checkout 123
+...
+git commit
+pt push</pre>
+
+ <h4><wicket:message key="gb.ptSimplifiedMerge"></wicket:message></h4>
+ <pre class="gitcommand">pt pull 123</pre>
+ <p><wicket:message key="gb.ptDescription2"></wicket:message></p>
+ </div>
+ <div class="modal-footer">
+ <a class="btn btn-appmenu" href="/pt" ><wicket:message key="gb.download"></wicket:message></a>
+ </div>
+</div>
+
+
+<!-- MILESTONE PROGRESS FRAGMENT -->
+<wicket:fragment wicket:id="milestoneProgressFragment">
+ <div style="display:inline-block;padding-right: 10px" wicket:id="link"></div>
+ <div style="display:inline-block;margin-bottom: 0px;width: 100px;height:10px;" class="progress progress-success">
+ <div class="bar" wicket:id="progress"></div>
+ </div>
+</wicket:fragment>
+
+
+<!-- TICKET CONTROLS FRAGMENT -->
+<wicket:fragment wicket:id="controlsFragment">
+ <div class="hidden-phone hidden-tablet">
+ <div class="btn-group" style="display:inline-block;">
+ <a class="btn btn-small dropdown-toggle" data-toggle="dropdown" href="#"><wicket:message key="gb.status"></wicket:message> <span class="caret"></span></a>
+ <ul class="dropdown-menu">
+ <li wicket:id="newStatus"><a wicket:id="link">[status]</a></li>
+ </ul>
+ </div>
+
+ <div class="btn-group" style="display:inline-block;">
+ <a class="btn btn-small dropdown-toggle" data-toggle="dropdown" href="#"><wicket:message key="gb.responsible"></wicket:message> <span class="caret"></span></a>
+ <ul class="dropdown-menu">
+ <li wicket:id="newResponsible"><a wicket:id="link">[responsible]</a></li>
+ </ul>
+ </div>
+
+ <div class="btn-group" style="display:inline-block;">
+ <a class="btn btn-small dropdown-toggle" data-toggle="dropdown" href="#"><wicket:message key="gb.milestone"></wicket:message> <span class="caret"></span></a>
+ <ul class="dropdown-menu">
+ <li wicket:id="newMilestone"><a wicket:id="link">[milestone]</a></li>
+ </ul>
+ </div>
+
+ <div class="btn-group" style="display:inline-block;">
+ <a class="btn btn-small" wicket:id="editLink"></a>
+ </div>
+ </div>
+</wicket:fragment>
+
+
+<!-- STATUS INDICATOR FRAGMENT -->
+<wicket:fragment wicket:id="ticketStatusFragment">
+ <div style="font-size:2.5em;padding-bottom: 5px;">
+ <i wicket:id="ticketIcon">[ticket type]</i>
+ </div>
+ <div style="font-size:1.5em;" wicket:id="ticketStatus">[ticket status]</div>
+</wicket:fragment>
+
+
+<!-- DISCUSSION FRAGMENT -->
+<wicket:fragment wicket:id="discussionFragment">
+ <h3 style="padding-top:10px;"><wicket:message key="gb.comments"></wicket:message></h3>
+ <div wicket:id="discussion">
+ <div style="padding: 10px 0px;" wicket:id="entry"></div>
+ </div>
+</wicket:fragment>
+
+<!-- NEW COMMENT FRAGMENT -->
+<wicket:fragment wicket:id="newCommentFragment">
+ <div class="row">
+ <div class="span8">
+ <hr/>
+ </div>
+ </div>
+
+ <h3 style="padding:0px 0px 10px;"><wicket:message key="gb.addComment"></wicket:message></h3>
+
+ <div class="row">
+ <div class="span1 hidden-phone" style="text-align:right;">
+ <span wicket:id="newCommentAvatar">[avatar]</span>
+ </div>
+ <div class="span7 attribution-border" style="background-color:#fbfbfb;">
+ <div class="hidden-phone attribution-triangle"></div>
+ <div wicket:id="commentPanel"></div>
+ </div>
+ </div>
+</wicket:fragment>
+
+
+<!-- COMMENT FRAGMENT -->
+<wicket:fragment wicket:id="commentFragment">
+<div class="row">
+ <div class="span1 hidden-phone" style="text-align:right;">
+ <span wicket:id="changeAvatar">[avatar]</span>
+ </div>
+ <div class="span7 attribution-border">
+ <!-- <div class="hidden-phone attribution-triangle"></div> -->
+ <div class="attribution-header" style="border-radius:20px;">
+ <span class="indicator-large-dark"><i wicket:id="commentIcon"></i></span><span class="attribution-emphasize" wicket:id="changeAuthor">[author]</span> <span class="attribution-text"><span class="hidden-phone"><wicket:message key="gb.commented">[commented]</wicket:message></span></span><p class="attribution-header-pullright" ><span class="attribution-date" wicket:id="changeDate">[comment date]</span><a class="attribution-link" wicket:id="changeLink"><i class="iconic-link"></i></a></p>
+ </div>
+ <div class="markdown attribution-comment">
+ <div class="ticket-text" wicket:id="comment">[comment text]</div>
+ </div>
+ </div>
+</div>
+</wicket:fragment>
+
+
+<!-- STATUS CHANGE FRAGMENT -->
+<wicket:fragment wicket:id="statusFragment">
+<div class="row" style="opacity: 0.5;filter: alpha(opacity=50);">
+ <div class="span7 offset1">
+ <div style="padding: 8px;border: 1px solid translucent;">
+ <span class="indicator-large-dark"><i></i></span><span class="attribution-emphasize" wicket:id="changeAuthor">[author]</span> <span class="attribution-text"><span class="hidden-phone"><wicket:message key="gb.changedStatus">[changed status]</wicket:message></span></span> <span style="padding-left:10px;"><span wicket:id="statusChange"></span></span><p class="attribution-header-pullright" ><span class="attribution-date" wicket:id="changeDate">[comment date]</span><a class="attribution-link" wicket:id="changeLink"><i class="iconic-link"></i></a></p>
+ </div>
+ </div>
+</div>
+</wicket:fragment>
+
+
+<!-- BOUNDARY FRAGMENT -->
+<wicket:fragment wicket:id="boundaryFragment">
+<div class="row" style="padding: 15px 0px 10px 0px;">
+ <div class="span7 offset1" style="border-top: 2px dotted #999;" />
+</div>
+</wicket:fragment>
+
+
+<!-- MERGE/CLOSE FRAGMENT -->
+<wicket:fragment wicket:id="mergeCloseFragment">
+<div wicket:id="merge" style="padding-top: 10px;"></div>
+<div wicket:id="close"></div>
+<div wicket:id="boundary"></div>
+</wicket:fragment>
+
+
+<!-- MERGE FRAGMENT -->
+<wicket:fragment wicket:id="mergeFragment">
+<div class="row">
+ <div class="span7 offset1">
+ <span class="status-change aui-lozenge aui-lozenge-success"><wicket:message key="gb.merged"></wicket:message></span>
+ <span class="attribution-emphasize" wicket:id="changeAuthor">[author]</span> <span class="attribution-text"><wicket:message key="gb.mergedPatchset">[merged patchset]</wicket:message></span>
+ <span class="attribution-emphasize" wicket:id="commitLink">[commit]</span> <span style="padding-left:2px;" wicket:id="toBranch"></span>
+ <p class="attribution-pullright"><span class="attribution-date" wicket:id="changeDate">[change date]</span></p>
+ </div>
+</div>
+</wicket:fragment>
+
+
+<!-- PROPOSE A PATCHSET FRAGMENT -->
+<wicket:fragment wicket:id="proposeFragment">
+ <div class="featureWelcome">
+ <div class="row">
+ <div class="icon span2 hidden-phone"><i class="fa fa-code"></i></div>
+ <div class="span9">
+ <h1><wicket:message key="gb.proposePatchset"></wicket:message></h1>
+ <div class="markdown">
+ <p><wicket:message key="gb.proposePatchsetNote"></wicket:message></p>
+ <p><span wicket:id="proposeInstructions"></span></p>
+ <h4><span wicket:id="gitWorkflow"></span></h4>
+ <div wicket:id="gitWorkflowSteps"></div>
+ <h4><span wicket:id="ptWorkflow"></span> <small><wicket:message key="gb.ptDescription"></wicket:message> (<a href="#ptModal" role="button" data-toggle="modal"><wicket:message key="gb.about"></wicket:message></a>)</small></h4>
+ <div wicket:id="ptWorkflowSteps"></div>
+ </div>
+ </div>
+ </div>
+ </div>
+</wicket:fragment>
+
+
+<!-- PATCHSET FRAGMENT -->
+<wicket:fragment wicket:id="patchsetFragment">
+ <div class="row" style="padding: 0px 0px 20px;">
+ <div class="span12 attribution-border">
+ <div wicket:id="panel"></div>
+ </div>
+ </div>
+
+ <h3><span wicket:id="commitsInPatchset"></span></h3>
+ <div class="row">
+ <div class="span12">
+ <table class="table tickets">
+ <thead>
+ <tr>
+ <th class="hidden-phone"><wicket:message key="gb.author"></wicket:message></th>
+ <th ><wicket:message key="gb.commit"></wicket:message></th>
+ <th colspan="2"><wicket:message key="gb.title"></wicket:message></th>
+ <th style="text-align: right;"><wicket:message key="gb.date"></wicket:message></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr wicket:id="commit">
+ <td class="hidden-phone"><span wicket:id="authorAvatar">[avatar]</span> <span wicket:id="author">[author]</span></td>
+ <td><span class="shortsha1" wicket:id="commitId">[commit id]</span><span class="hidden-phone" style="padding-left: 20px;" wicket:id="diff">[diff]</span></td>
+ <td><span class="attribution-text" wicket:id="title">[title]</span></td>
+ <td style="padding:8px 0px;text-align:right;"><span style="padding-right:40px;"><span wicket:id="commitDiffStat"></span></span></td>
+ <td style="text-align:right;"><span class="attribution-date" wicket:id="commitDate">[commit date]</span></td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+</wicket:fragment>
+
+
+<!-- COLLAPSIBLE PATCHSET (temp) -->
+<wicket:fragment wicket:id="collapsiblePatchsetFragment">
+<div wicket:id="mergePanel" style="margin-bottom: 10px;"></div>
+<div class="accordion" id="accordionPatchset" style="clear:both;margin: 0px;">
+<div class="patch-group">
+ <div class="accordion-heading">
+ <div class="attribution-patch-pullright">
+ <div style="padding-bottom: 2px;">
+ <span class="attribution-date" wicket:id="changeDate">[patch date]</span>
+ </div>
+
+ <!-- Client commands menu -->
+ <div class="btn-group pull-right hidden-phone hidden-tablet">
+ <a class="btn btn-mini btn-appmenu" data-toggle="collapse" data-parent="#accordionCheckout" href="#bodyCheckout"><wicket:message key="gb.checkout"></wicket:message> <span class="caret"></span></a>
+ </div>
+
+ <!-- Compare Patchsets menu -->
+ <div class="btn-group pull-right hidden-phone hidden-tablet" style="padding-right: 5px;">
+ <a class="btn btn-mini dropdown-toggle" data-toggle="dropdown" href="#">
+ <wicket:message key="gb.compare"></wicket:message> <span class="caret"></span>
+ </a>
+ <ul class="dropdown-menu">
+ <li><span wicket:id="compareMergeBase"></span></li>
+ <li wicket:id="comparePatch"><span wicket:id="compareLink"></span></li>
+ </ul>
+ </div>
+
+ </div>
+ <div style="padding:8px 10px;">
+ <div>
+ <span class="attribution-emphasize" wicket:id="changeAuthor">[author]</span> <span class="attribution-text"><span wicket:id="uploadedWhat"></span></span>
+ <a wicket:message="title:gb.showHideDetails" data-toggle="collapse" data-parent="#accordionPatchset" href="#bodyPatchset"><i class="fa fa-toggle-down"></i></a>
+ </div>
+ <div wicket:id="patchsetStat"></div>
+ </div>
+ </div>
+
+ <div style="padding: 10px;color: #444;background:white;border-top:1px solid #ccc;">
+ <div class="pull-right" wicket:id="reviewControls"></div>
+ <span style="font-weight:bold;padding-right:10px;"><wicket:message key="gb.reviews"></wicket:message></span> <span wicket:id="reviews" style="padding-right:10px;"><i style="font-size:16px;" wicket:id="score"></i> <span wicket:id="reviewer"></span></span>
+ </div>
+
+ <div id="bodyPatchset" class="accordion-body collapse" style="clear:both;">
+ <div class="accordion-inner">
+ <!-- changed paths -->
+ <table class="pretty" style="border: 0px;">
+ <tr wicket:id="changedPath">
+ <td class="changeType"><span wicket:id="changeType">[change type]</span></td>
+ <td class="path"><span wicket:id="pathName">[commit path]</span></td>
+ <td class="hidden-phone rightAlign">
+ <span class="hidden-tablet" style="padding-right:20px;" wicket:id="diffStat"></span>
+ <span class="link" style="white-space: nowrap;">
+ <a wicket:id="diff"><wicket:message key="gb.diff"></wicket:message></a> | <a wicket:id="view"><wicket:message key="gb.view"></wicket:message></a>
+ </span>
+ </td>
+ </tr>
+ </table>
+ </div>
+ </div>
+</div>
+<div id="bodyCheckout" class="accordion-body collapse" style="background-color:#fbfbfb;clear:both;">
+ <div class="alert submit-info" style="padding:4px;">
+ <div class="merge-panel" style="border: 1px solid #F1CB82;">
+ <div class="ticket-text">
+ <h4><wicket:message key="gb.checkoutViaCommandLine"></wicket:message></h4>
+ <p><wicket:message key="gb.checkoutViaCommandLineNote"></wicket:message></p>
+
+ <h4>Git</h4>
+ <p class="step">
+ <b><span wicket:id="gitStep1"></span>:</b> <wicket:message key="gb.checkoutStep1"></wicket:message> <span wicket:id="gitCopyStep1"></span>
+ </p>
+ <pre wicket:id="gitPreStep1" class="gitcommand"></pre>
+ <p class="step">
+ <b><span wicket:id="gitStep2"></span>:</b> <wicket:message key="gb.checkoutStep2"></wicket:message> <span wicket:id="gitCopyStep2"></span>
+ </p>
+ <pre wicket:id="gitPreStep2" class="gitcommand"></pre>
+
+ <hr/>
+ <h4>Barnum <small><wicket:message key="gb.ptDescription"></wicket:message> (<a href="#ptModal" role="button" data-toggle="modal"><wicket:message key="gb.about"></wicket:message></a>)</small> </h4>
+ <p class="step">
+ <wicket:message key="gb.ptCheckout"></wicket:message> <span wicket:id="ptCopyStep"></span>
+ </p>
+ <pre wicket:id="ptPreStep" class="gitcommand"></pre>
+ </div>
+ </div>
+ </div>
+</div>
+</div>
+</wicket:fragment>
+
+<!--ACTIVITY -->
+<wicket:fragment wicket:id="activityFragment">
+ <table class="table tickets">
+ <thead>
+ <tr>
+ <th><wicket:message key="gb.author"></wicket:message></th>
+ <th colspan='3'><wicket:message key="gb.action"></wicket:message></th>
+ <th style="text-align: right;"><wicket:message key="gb.date"></wicket:message></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr wicket:id="event">
+ <td><span class="hidden-phone" wicket:id="changeAvatar">[avatar]</span> <span class="attribution-emphasize" wicket:id="changeAuthor">[author]</span></td>
+ <td>
+ <span class="attribution-txt"><span wicket:id="what">[what happened]</span></span>
+ <div wicket:id="fields"></div>
+ </td>
+ <td style="text-align:right;">
+ <span wicket:id="patchsetType">[revision type]</span>
+ </td>
+ <td><span class="hidden-phone hidden-tablet aui-lozenge aui-lozenge-subtle" wicket:id="patchsetRevision">[R1]</span>
+ <span class="hidden-tablet hidden-phone" style="padding-left:15px;"><span wicket:id="patchsetDiffStat"></span></span>
+ </td>
+ <td style="text-align:right;"><span class="attribution-date" wicket:id="changeDate">[patch date]</span></td>
+ </tr>
+ </tbody>
+ </table>
+</wicket:fragment>
+
+
+<!-- REVIEW CONTROLS -->
+<wicket:fragment wicket:id="reviewControlsFragment">
+ <div class="btn-group pull-right hidden-phone hidden-tablet">
+ <a class="btn btn-mini dropdown-toggle" data-toggle="dropdown" href="#">
+ <wicket:message key="gb.review"></wicket:message> <span class="caret"></span>
+ </a>
+ <ul class="dropdown-menu">
+ <li><span><a wicket:id="approveLink">approve</a></span></li>
+ <li><span><a wicket:id="looksGoodLink">looks good</a></span></li>
+ <li><span><a wicket:id="needsImprovementLink">needs improvement</a></span></li>
+ <li><span><a wicket:id="vetoLink">veto</a></span></li>
+ </ul>
+ </div>
+</wicket:fragment>
+
+
+<!-- MERGEABLE PATCHSET FRAGMENT -->
+<wicket:fragment wicket:id="mergeableFragment">
+ <div class="alert alert-success submit-info" style="padding:4px;">
+ <div class="merge-panel" style="border: 1px solid rgba(70, 136, 70, 0.5);">
+ <div class="pull-right" style="padding-top:5px;">
+ <a class="btn btn-success" wicket:id="mergeButton"></a>
+ </div>
+ <h4><i class="fa fa-check-circle"></i> <span wicket:id="mergeTitle"></span></h4>
+ <div wicket:id="mergeMore"></div>
+ </div>
+ </div>
+</wicket:fragment>
+
+
+<!-- COMMAND LINE MERGE INSTRUCTIONS -->
+<wicket:fragment wicket:id="commandlineMergeFragment">
+ <div class="accordion" id="accordionInstructions" style="margin: 0px;">
+ <span wicket:id="instructions"></span>
+ <a wicket:message="title:gb.showHideDetails" data-toggle="collapse" data-parent="#accordionInstructions" href="#bodyInstructions"><i class="fa fa-toggle-down"></i></a>
+ </div>
+
+ <div id="bodyInstructions" class="ticket-text accordion-body collapse" style="clear:both;">
+ <hr/>
+ <h4><wicket:message key="gb.mergingViaCommandLine"></wicket:message></h4>
+ <p><wicket:message key="gb.mergingViaCommandLineNote"></wicket:message></p>
+
+ <h4>Git</h4>
+ <p class="step">
+ <b><span wicket:id="mergeStep1"></span>:</b> <wicket:message key="gb.mergeStep1"></wicket:message> <span wicket:id="mergeCopyStep1"></span>
+ </p>
+ <pre wicket:id="mergePreStep1" class="gitcommand"></pre>
+ <p class="step">
+ <b><span wicket:id="mergeStep2"></span>:</b> <wicket:message key="gb.mergeStep2"></wicket:message> <span wicket:id="mergeCopyStep2"></span>
+ </p>
+ <pre wicket:id="mergePreStep2" class="gitcommand"></pre>
+ <p class="step">
+ <b><span wicket:id="mergeStep3"></span>:</b> <wicket:message key="gb.mergeStep3"></wicket:message> <span wicket:id="mergeCopyStep3"></span>
+ </p>
+ <pre wicket:id="mergePreStep3" class="gitcommand"></pre>
+
+ <hr/>
+ <h4>Barnum <small><wicket:message key="gb.ptDescription"></wicket:message> (<a href="#ptModal" role="button" data-toggle="modal"><wicket:message key="gb.about"></wicket:message></a>)</small></h4>
+ <p class="step">
+ <wicket:message key="gb.ptMerge"></wicket:message> <span wicket:id="ptMergeCopyStep"></span>
+ </p>
+ <pre wicket:id="ptMergeStep" class="gitcommand"></pre>
+ </div>
+</wicket:fragment>
+
+
+<!-- ALREADY MERGED FRAGMENT -->
+<wicket:fragment wicket:id="alreadyMergedFragment">
+ <div class="alert alert-success submit-info" style="padding:4px;">
+ <div class="merge-panel" style="border: 1px solid rgba(70, 136, 70, 0.5);">
+ <h4><i class="fa fa-check-circle"></i> <span wicket:id="mergeTitle"></span></h4>
+ </div>
+ </div>
+</wicket:fragment>
+
+
+<!-- NOT-MERGEABLE FRAGMENT -->
+<wicket:fragment wicket:id="notMergeableFragment">
+ <div class="alert alert-error submit-info" style="padding:4px;">
+ <div class="merge-panel" style="border: 1px solid rgba(136, 70, 70, 0.5);">
+ <h4><i class="fa fa-exclamation-triangle"></i> <span wicket:id="mergeTitle"></span></h4>
+ <div wicket:id="mergeMore"></div>
+ </div>
+ </div>
+</wicket:fragment>
+
+
+<!-- VETOED PATCHSET FRAGMENT -->
+<wicket:fragment wicket:id="vetoedFragment">
+ <div class="alert alert-error submit-info" style="padding:4px;">
+ <div class="merge-panel" style="border: 1px solid rgba(136, 70, 70, 0.5);">
+ <h4><i class="fa fa-exclamation-circle"></i> <span wicket:id="mergeTitle"></span></h4>
+ <wicket:message key="gb.patchsetVetoedMore"></wicket:message>
+ </div>
+ </div>
+</wicket:fragment>
+
+
+<!-- NOT APPROVED PATCHSET FRAGMENT -->
+<wicket:fragment wicket:id="notApprovedFragment">
+ <div class="alert alert-info submit-info" style="padding:4px;">
+ <div class="merge-panel" style="border: 1px solid rgba(0, 70, 200, 0.5);">
+ <h4><i class="fa fa-minus-circle"></i> <span wicket:id="mergeTitle"></span></h4>
+ <div wicket:id="mergeMore"></div>
+ </div>
+ </div>
+</wicket:fragment>
+
+
+<!-- Plain JavaScript manual copy & paste -->
+<wicket:fragment wicket:id="jsPanel">
+ <span style="vertical-align:baseline;">
+ <img wicket:id="copyIcon" wicket:message="title:gb.copyToClipboard"></img>
+ </span>
+</wicket:fragment>
+
+
+<!-- flash-based button-press copy & paste -->
+<wicket:fragment wicket:id="clippyPanel">
+ <object wicket:message="title:gb.copyToClipboard" style="vertical-align:middle;"
+ wicket:id="clippy"
+ width="14"
+ height="14"
+ bgcolor="#ffffff"
+ quality="high"
+ wmode="transparent"
+ scale="noscale"
+ allowScriptAccess="always"></object>
+</wicket:fragment>
+
+</wicket:extend>
+</body>
+</html>
\ No newline at end of file diff --git a/src/main/java/com/gitblit/wicket/pages/TicketPage.java b/src/main/java/com/gitblit/wicket/pages/TicketPage.java new file mode 100644 index 00000000..0d60ec20 --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/TicketPage.java @@ -0,0 +1,1527 @@ +/*
+ * Copyright 2014 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.wicket.pages;
+
+import java.text.DateFormat;
+import java.text.MessageFormat;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TimeZone;
+import java.util.TreeSet;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.apache.wicket.AttributeModifier;
+import org.apache.wicket.Component;
+import org.apache.wicket.MarkupContainer;
+import org.apache.wicket.PageParameters;
+import org.apache.wicket.RestartResponseException;
+import org.apache.wicket.ajax.AjaxRequestTarget;
+import org.apache.wicket.behavior.IBehavior;
+import org.apache.wicket.behavior.SimpleAttributeModifier;
+import org.apache.wicket.markup.html.basic.Label;
+import org.apache.wicket.markup.html.image.ContextImage;
+import org.apache.wicket.markup.html.link.BookmarkablePageLink;
+import org.apache.wicket.markup.html.link.ExternalLink;
+import org.apache.wicket.markup.html.panel.Fragment;
+import org.apache.wicket.markup.repeater.Item;
+import org.apache.wicket.markup.repeater.data.DataView;
+import org.apache.wicket.markup.repeater.data.ListDataProvider;
+import org.apache.wicket.model.Model;
+import org.apache.wicket.protocol.http.WebRequest;
+import org.eclipse.jgit.diff.DiffEntry.ChangeType;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.URIish;
+
+import com.gitblit.Constants;
+import com.gitblit.Constants.AccessPermission;
+import com.gitblit.Keys;
+import com.gitblit.git.PatchsetCommand;
+import com.gitblit.git.PatchsetReceivePack;
+import com.gitblit.models.PathModel.PathChangeModel;
+import com.gitblit.models.RegistrantAccessPermission;
+import com.gitblit.models.RepositoryModel;
+import com.gitblit.models.SubmoduleModel;
+import com.gitblit.models.TicketModel;
+import com.gitblit.models.TicketModel.Change;
+import com.gitblit.models.TicketModel.CommentSource;
+import com.gitblit.models.TicketModel.Field;
+import com.gitblit.models.TicketModel.Patchset;
+import com.gitblit.models.TicketModel.PatchsetType;
+import com.gitblit.models.TicketModel.Review;
+import com.gitblit.models.TicketModel.Score;
+import com.gitblit.models.TicketModel.Status;
+import com.gitblit.models.UserModel;
+import com.gitblit.tickets.TicketIndexer.Lucene;
+import com.gitblit.tickets.TicketLabel;
+import com.gitblit.tickets.TicketMilestone;
+import com.gitblit.tickets.TicketResponsible;
+import com.gitblit.utils.JGitUtils;
+import com.gitblit.utils.JGitUtils.MergeStatus;
+import com.gitblit.utils.MarkdownUtils;
+import com.gitblit.utils.StringUtils;
+import com.gitblit.utils.TimeUtils;
+import com.gitblit.wicket.GitBlitWebSession;
+import com.gitblit.wicket.WicketUtils;
+import com.gitblit.wicket.panels.BasePanel.JavascriptTextPrompt;
+import com.gitblit.wicket.panels.CommentPanel;
+import com.gitblit.wicket.panels.DiffStatPanel;
+import com.gitblit.wicket.panels.GravatarImage;
+import com.gitblit.wicket.panels.IconAjaxLink;
+import com.gitblit.wicket.panels.LinkPanel;
+import com.gitblit.wicket.panels.ShockWaveComponent;
+import com.gitblit.wicket.panels.SimpleAjaxLink;
+
+/**
+ * The ticket page handles viewing and updating a ticket.
+ *
+ * @author James Moger
+ *
+ */
+public class TicketPage extends TicketBasePage {
+
+ static final String NIL = "<nil>";
+
+ static final String ESC_NIL = StringUtils.escapeForHtml(NIL, false);
+
+ final int avatarWidth = 40;
+
+ final TicketModel ticket;
+
+ public TicketPage(PageParameters params) {
+ super(params);
+
+ final UserModel user = GitBlitWebSession.get().getUser() == null ? UserModel.ANONYMOUS : GitBlitWebSession.get().getUser();
+ final boolean isAuthenticated = !UserModel.ANONYMOUS.equals(user) && user.isAuthenticated;
+ final RepositoryModel repository = getRepositoryModel();
+ final String id = WicketUtils.getObject(params);
+ long ticketId = Long.parseLong(id);
+ ticket = app().tickets().getTicket(repository, ticketId);
+
+ if (ticket == null) {
+ // ticket not found
+ throw new RestartResponseException(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
+ }
+
+ final List<Change> revisions = new ArrayList<Change>();
+ List<Change> comments = new ArrayList<Change>();
+ List<Change> statusChanges = new ArrayList<Change>();
+ List<Change> discussion = new ArrayList<Change>();
+ for (Change change : ticket.changes) {
+ if (change.hasComment() || (change.isStatusChange() && (change.getStatus() != Status.New))) {
+ discussion.add(change);
+ }
+ if (change.hasComment()) {
+ comments.add(change);
+ }
+ if (change.hasPatchset()) {
+ revisions.add(change);
+ }
+ if (change.isStatusChange() && !change.hasPatchset()) {
+ statusChanges.add(change);
+ }
+ }
+
+ final Change currentRevision = revisions.isEmpty() ? null : revisions.get(revisions.size() - 1);
+ final Patchset currentPatchset = ticket.getCurrentPatchset();
+
+ /*
+ * TICKET HEADER
+ */
+ String href = urlFor(TicketsPage.class, params).toString();
+ add(new ExternalLink("ticketNumber", href, "#" + ticket.number));
+ Label headerStatus = new Label("headerStatus", ticket.status.toString());
+ WicketUtils.setCssClass(headerStatus, getLozengeClass(ticket.status, false));
+ add(headerStatus);
+ add(new Label("ticketTitle", ticket.title));
+ if (currentPatchset == null) {
+ add(new Label("diffstat").setVisible(false));
+ } else {
+ // calculate the current diffstat of the patchset
+ add(new DiffStatPanel("diffstat", ticket.insertions, ticket.deletions));
+ }
+
+
+ /*
+ * TAB TITLES
+ */
+ add(new Label("commentCount", "" + comments.size()).setVisible(!comments.isEmpty()));
+ add(new Label("commitCount", "" + (currentPatchset == null ? 0 : currentPatchset.commits)).setVisible(currentPatchset != null));
+
+
+ /*
+ * TICKET AUTHOR and DATE (DISCUSSION TAB)
+ */
+ UserModel createdBy = app().users().getUserModel(ticket.createdBy);
+ if (createdBy == null) {
+ add(new Label("whoCreated", ticket.createdBy));
+ } else {
+ add(new LinkPanel("whoCreated", null, createdBy.getDisplayName(),
+ UserPage.class, WicketUtils.newUsernameParameter(createdBy.username)));
+ }
+
+ if (ticket.isProposal()) {
+ // clearly indicate this is a change ticket
+ add(new Label("creationMessage", getString("gb.proposedThisChange")));
+ } else {
+ // standard ticket
+ add(new Label("creationMessage", getString("gb.createdThisTicket")));
+ }
+
+ String dateFormat = app().settings().getString(Keys.web.datestampLongFormat, "EEEE, MMMM d, yyyy");
+ String timestampFormat = app().settings().getString(Keys.web.datetimestampLongFormat, "EEEE, MMMM d, yyyy");
+ final TimeZone timezone = getTimeZone();
+ final DateFormat df = new SimpleDateFormat(dateFormat);
+ df.setTimeZone(timezone);
+ final DateFormat tsf = new SimpleDateFormat(timestampFormat);
+ tsf.setTimeZone(timezone);
+ final Calendar cal = Calendar.getInstance(timezone);
+
+ String fuzzydate;
+ TimeUtils tu = getTimeUtils();
+ Date createdDate = ticket.created;
+ if (TimeUtils.isToday(createdDate, timezone)) {
+ fuzzydate = tu.today();
+ } else if (TimeUtils.isYesterday(createdDate, timezone)) {
+ fuzzydate = tu.yesterday();
+ } else {
+ // calculate a fuzzy time ago date
+ cal.setTime(createdDate);
+ cal.set(Calendar.HOUR_OF_DAY, 0);
+ cal.set(Calendar.MINUTE, 0);
+ cal.set(Calendar.SECOND, 0);
+ cal.set(Calendar.MILLISECOND, 0);
+ createdDate = cal.getTime();
+ fuzzydate = getTimeUtils().timeAgo(createdDate);
+ }
+ Label when = new Label("whenCreated", fuzzydate + ", " + df.format(createdDate));
+ WicketUtils.setHtmlTooltip(when, tsf.format(ticket.created));
+ add(when);
+
+ String exportHref = urlFor(ExportTicketPage.class, params).toString();
+ add(new ExternalLink("exportJson", exportHref, "json"));
+
+
+ /*
+ * RESPONSIBLE (DISCUSSION TAB)
+ */
+ if (StringUtils.isEmpty(ticket.responsible)) {
+ add(new Label("responsible"));
+ } else {
+ UserModel responsible = app().users().getUserModel(ticket.responsible);
+ if (responsible == null) {
+ add(new Label("responsible", ticket.responsible));
+ } else {
+ add(new LinkPanel("responsible", null, responsible.getDisplayName(),
+ UserPage.class, WicketUtils.newUsernameParameter(responsible.username)));
+ }
+ }
+
+ /*
+ * MILESTONE PROGRESS (DISCUSSION TAB)
+ */
+ if (StringUtils.isEmpty(ticket.milestone)) {
+ add(new Label("milestone"));
+ } else {
+ // link to milestone query
+ TicketMilestone milestone = app().tickets().getMilestone(repository, ticket.milestone);
+ PageParameters milestoneParameters = new PageParameters();
+ milestoneParameters.put("r", repositoryName);
+ milestoneParameters.put(Lucene.milestone.name(), ticket.milestone);
+ int progress = 0;
+ int open = 0;
+ int closed = 0;
+ if (milestone != null) {
+ progress = milestone.getProgress();
+ open = milestone.getOpenTickets();
+ closed = milestone.getClosedTickets();
+ }
+
+ Fragment milestoneProgress = new Fragment("milestone", "milestoneProgressFragment", this);
+ milestoneProgress.add(new LinkPanel("link", null, ticket.milestone, TicketsPage.class, milestoneParameters));
+ Label label = new Label("progress");
+ WicketUtils.setCssStyle(label, "width:" + progress + "%;");
+ milestoneProgress.add(label);
+ WicketUtils.setHtmlTooltip(milestoneProgress, MessageFormat.format("{0} open, {1} closed", open, closed));
+ add(milestoneProgress);
+ }
+
+
+ /*
+ * TICKET DESCRIPTION (DISCUSSION TAB)
+ */
+ String desc;
+ if (StringUtils.isEmpty(ticket.body)) {
+ desc = getString("gb.noDescriptionGiven");
+ } else {
+ desc = MarkdownUtils.transformGFM(app().settings(), ticket.body, ticket.repository);
+ }
+ add(new Label("ticketDescription", desc).setEscapeModelStrings(false));
+
+
+ /*
+ * PARTICIPANTS (DISCUSSION TAB)
+ */
+ if (app().settings().getBoolean(Keys.web.allowGravatar, true)) {
+ // gravatar allowed
+ List<String> participants = ticket.getParticipants();
+ add(new Label("participantsLabel", MessageFormat.format(getString(participants.size() > 1 ? "gb.nParticipants" : "gb.oneParticipant"),
+ "<b>" + participants.size() + "</b>")).setEscapeModelStrings(false));
+ ListDataProvider<String> participantsDp = new ListDataProvider<String>(participants);
+ DataView<String> participantsView = new DataView<String>("participants", participantsDp) {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void populateItem(final Item<String> item) {
+ String username = item.getModelObject();
+ UserModel user = app().users().getUserModel(username);
+ if (user == null) {
+ user = new UserModel(username);
+ }
+ item.add(new GravatarImage("participant", user.getDisplayName(),
+ user.emailAddress, null, 25, true));
+ }
+ };
+ add(participantsView);
+ } else {
+ // gravatar prohibited
+ add(new Label("participantsLabel").setVisible(false));
+ add(new Label("participants").setVisible(false));
+ }
+
+
+ /*
+ * LARGE STATUS INDICATOR WITH ICON (DISCUSSION TAB->SIDE BAR)
+ */
+ Fragment ticketStatus = new Fragment("ticketStatus", "ticketStatusFragment", this);
+ Label ticketIcon = getStateIcon("ticketIcon", ticket);
+ ticketStatus.add(ticketIcon);
+ ticketStatus.add(new Label("ticketStatus", ticket.status.toString()));
+ WicketUtils.setCssClass(ticketStatus, getLozengeClass(ticket.status, false));
+ add(ticketStatus);
+
+
+ /*
+ * UPDATE FORM (DISCUSSION TAB)
+ */
+ if (isAuthenticated && app().tickets().isAcceptingTicketUpdates(repository)) {
+ Fragment controls = new Fragment("controls", "controlsFragment", this);
+
+
+ /*
+ * STATUS
+ */
+ List<Status> choices = new ArrayList<Status>();
+ if (ticket.isProposal()) {
+ choices.addAll(Arrays.asList(TicketModel.Status.proposalWorkflow));
+ } else if (ticket.isBug()) {
+ choices.addAll(Arrays.asList(TicketModel.Status.bugWorkflow));
+ } else {
+ choices.addAll(Arrays.asList(TicketModel.Status.requestWorkflow));
+ }
+ choices.remove(ticket.status);
+
+ ListDataProvider<Status> workflowDp = new ListDataProvider<Status>(choices);
+ DataView<Status> statusView = new DataView<Status>("newStatus", workflowDp) {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void populateItem(final Item<Status> item) {
+ SimpleAjaxLink<Status> link = new SimpleAjaxLink<Status>("link", item.getModel()) {
+
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void onClick(AjaxRequestTarget target) {
+ Status status = getModel().getObject();
+ Change change = new Change(user.username);
+ change.setField(Field.status, status);
+ if (!ticket.isWatching(user.username)) {
+ change.watch(user.username);
+ }
+ TicketModel update = app().tickets().updateTicket(repository, ticket.number, change);
+ app().tickets().createNotifier().sendMailing(update);
+ setResponsePage(TicketsPage.class, getPageParameters());
+ }
+ };
+ String css = getStatusClass(item.getModel().getObject());
+ WicketUtils.setCssClass(link, css);
+ item.add(link);
+ }
+ };
+ controls.add(statusView);
+
+ /*
+ * RESPONSIBLE LIST
+ */
+ Set<String> userlist = new TreeSet<String>(ticket.getParticipants());
+ for (RegistrantAccessPermission rp : app().repositories().getUserAccessPermissions(getRepositoryModel())) {
+ if (rp.permission.atLeast(AccessPermission.PUSH) && !rp.isTeam()) {
+ userlist.add(rp.registrant);
+ }
+ }
+ List<TicketResponsible> responsibles = new ArrayList<TicketResponsible>();
+ if (!StringUtils.isEmpty(ticket.responsible)) {
+ // exclude the current responsible
+ userlist.remove(ticket.responsible);
+ }
+ for (String username : userlist) {
+ UserModel u = app().users().getUserModel(username);
+ if (u != null) {
+ responsibles.add(new TicketResponsible(u));
+ }
+ }
+ Collections.sort(responsibles);
+ responsibles.add(new TicketResponsible(ESC_NIL, "", ""));
+ ListDataProvider<TicketResponsible> responsibleDp = new ListDataProvider<TicketResponsible>(responsibles);
+ DataView<TicketResponsible> responsibleView = new DataView<TicketResponsible>("newResponsible", responsibleDp) {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void populateItem(final Item<TicketResponsible> item) {
+ SimpleAjaxLink<TicketResponsible> link = new SimpleAjaxLink<TicketResponsible>("link", item.getModel()) {
+
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void onClick(AjaxRequestTarget target) {
+ TicketResponsible responsible = getModel().getObject();
+ Change change = new Change(user.username);
+ change.setField(Field.responsible, responsible.username);
+ if (!StringUtils.isEmpty(responsible.username)) {
+ if (!ticket.isWatching(responsible.username)) {
+ change.watch(responsible.username);
+ }
+ }
+ if (!ticket.isWatching(user.username)) {
+ change.watch(user.username);
+ }
+ TicketModel update = app().tickets().updateTicket(repository, ticket.number, change);
+ app().tickets().createNotifier().sendMailing(update);
+ setResponsePage(TicketsPage.class, getPageParameters());
+ }
+ };
+ item.add(link);
+ }
+ };
+ controls.add(responsibleView);
+
+ /*
+ * MILESTONE LIST
+ */
+ List<TicketMilestone> milestones = app().tickets().getMilestones(repository, Status.Open);
+ if (!StringUtils.isEmpty(ticket.milestone)) {
+ for (TicketMilestone milestone : milestones) {
+ if (milestone.name.equals(ticket.milestone)) {
+ milestones.remove(milestone);
+ break;
+ }
+ }
+ }
+ milestones.add(new TicketMilestone(ESC_NIL));
+ ListDataProvider<TicketMilestone> milestoneDp = new ListDataProvider<TicketMilestone>(milestones);
+ DataView<TicketMilestone> milestoneView = new DataView<TicketMilestone>("newMilestone", milestoneDp) {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void populateItem(final Item<TicketMilestone> item) {
+ SimpleAjaxLink<TicketMilestone> link = new SimpleAjaxLink<TicketMilestone>("link", item.getModel()) {
+
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void onClick(AjaxRequestTarget target) {
+ TicketMilestone milestone = getModel().getObject();
+ Change change = new Change(user.username);
+ if (NIL.equals(milestone.name)) {
+ change.setField(Field.milestone, "");
+ } else {
+ change.setField(Field.milestone, milestone.name);
+ }
+ if (!ticket.isWatching(user.username)) {
+ change.watch(user.username);
+ }
+ TicketModel update = app().tickets().updateTicket(repository, ticket.number, change);
+ app().tickets().createNotifier().sendMailing(update);
+ setResponsePage(TicketsPage.class, getPageParameters());
+ }
+ };
+ item.add(link);
+ }
+ };
+ controls.add(milestoneView);
+
+ String editHref = urlFor(EditTicketPage.class, params).toString();
+ controls.add(new ExternalLink("editLink", editHref, getString("gb.edit")));
+
+ add(controls);
+ } else {
+ add(new Label("controls").setVisible(false));
+ }
+
+
+ /*
+ * TICKET METADATA
+ */
+ add(new Label("ticketType", ticket.type.toString()));
+ if (StringUtils.isEmpty(ticket.topic)) {
+ add(new Label("ticketTopic").setVisible(false));
+ } else {
+ // process the topic using the bugtraq config to link things
+ String topic = messageProcessor().processPlainCommitMessage(getRepository(), repositoryName, ticket.topic);
+ add(new Label("ticketTopic", topic).setEscapeModelStrings(false));
+ }
+
+
+ /*
+ * VOTERS
+ */
+ List<String> voters = ticket.getVoters();
+ Label votersCount = new Label("votes", "" + voters.size());
+ if (voters.size() == 0) {
+ WicketUtils.setCssClass(votersCount, "badge");
+ } else {
+ WicketUtils.setCssClass(votersCount, "badge badge-info");
+ }
+ add(votersCount);
+ if (user.isAuthenticated) {
+ Model<String> model;
+ if (ticket.isVoter(user.username)) {
+ model = Model.of(getString("gb.removeVote"));
+ } else {
+ model = Model.of(MessageFormat.format(getString("gb.vote"), ticket.type.toString()));
+ }
+ SimpleAjaxLink<String> link = new SimpleAjaxLink<String>("voteLink", model) {
+
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void onClick(AjaxRequestTarget target) {
+ Change change = new Change(user.username);
+ if (ticket.isVoter(user.username)) {
+ change.unvote(user.username);
+ } else {
+ change.vote(user.username);
+ }
+ app().tickets().updateTicket(repository, ticket.number, change);
+ setResponsePage(TicketsPage.class, getPageParameters());
+ }
+ };
+ add(link);
+ } else {
+ add(new Label("voteLink").setVisible(false));
+ }
+
+
+ /*
+ * WATCHERS
+ */
+ List<String> watchers = ticket.getWatchers();
+ Label watchersCount = new Label("watchers", "" + watchers.size());
+ if (watchers.size() == 0) {
+ WicketUtils.setCssClass(watchersCount, "badge");
+ } else {
+ WicketUtils.setCssClass(watchersCount, "badge badge-info");
+ }
+ add(watchersCount);
+ if (user.isAuthenticated) {
+ Model<String> model;
+ if (ticket.isWatching(user.username)) {
+ model = Model.of(getString("gb.stopWatching"));
+ } else {
+ model = Model.of(MessageFormat.format(getString("gb.watch"), ticket.type.toString()));
+ }
+ SimpleAjaxLink<String> link = new SimpleAjaxLink<String>("watchLink", model) {
+
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void onClick(AjaxRequestTarget target) {
+ Change change = new Change(user.username);
+ if (ticket.isWatching(user.username)) {
+ change.unwatch(user.username);
+ } else {
+ change.watch(user.username);
+ }
+ app().tickets().updateTicket(repository, ticket.number, change);
+ setResponsePage(TicketsPage.class, getPageParameters());
+ }
+ };
+ add(link);
+ } else {
+ add(new Label("watchLink").setVisible(false));
+ }
+
+
+ /*
+ * TOPIC & LABELS (DISCUSSION TAB->SIDE BAR)
+ */
+ ListDataProvider<String> labelsDp = new ListDataProvider<String>(ticket.getLabels());
+ DataView<String> labelsView = new DataView<String>("labels", labelsDp) {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void populateItem(final Item<String> item) {
+ final String value = item.getModelObject();
+ Label label = new Label("label", value);
+ TicketLabel tLabel = app().tickets().getLabel(repository, value);
+ String background = MessageFormat.format("background-color:{0};", tLabel.color);
+ label.add(new SimpleAttributeModifier("style", background));
+ item.add(label);
+ }
+ };
+
+ add(labelsView);
+
+
+ /*
+ * COMMENTS & STATUS CHANGES (DISCUSSION TAB)
+ */
+ if (comments.size() == 0) {
+ add(new Label("discussion").setVisible(false));
+ } else {
+ Fragment discussionFragment = new Fragment("discussion", "discussionFragment", this);
+ ListDataProvider<Change> discussionDp = new ListDataProvider<Change>(discussion);
+ DataView<Change> discussionView = new DataView<Change>("discussion", discussionDp) {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void populateItem(final Item<Change> item) {
+ final Change entry = item.getModelObject();
+ if (entry.isMerge()) {
+ /*
+ * MERGE
+ */
+ String resolvedBy = entry.getString(Field.mergeSha);
+
+ // identify the merged patch, it is likely the last
+ Patchset mergedPatch = null;
+ for (Change c : revisions) {
+ if (c.patchset.tip.equals(resolvedBy)) {
+ mergedPatch = c.patchset;
+ break;
+ }
+ }
+
+ String commitLink;
+ if (mergedPatch == null) {
+ // shouldn't happen, but just-in-case
+ int len = app().settings().getInteger(Keys.web.shortCommitIdLength, 6);
+ commitLink = resolvedBy.substring(0, len);
+ } else {
+ // expected result
+ commitLink = mergedPatch.toString();
+ }
+
+ Fragment mergeFragment = new Fragment("entry", "mergeFragment", this);
+ mergeFragment.add(new LinkPanel("commitLink", null, commitLink,
+ CommitPage.class, WicketUtils.newObjectParameter(repositoryName, resolvedBy)));
+ mergeFragment.add(new Label("toBranch", MessageFormat.format(getString("gb.toBranch"),
+ "<b>" + ticket.mergeTo + "</b>")).setEscapeModelStrings(false));
+ addUserAttributions(mergeFragment, entry, 0);
+ addDateAttributions(mergeFragment, entry);
+
+ item.add(mergeFragment);
+ } else if (entry.isStatusChange()) {
+ /*
+ * STATUS CHANGE
+ */
+ Fragment frag = new Fragment("entry", "statusFragment", this);
+ Label status = new Label("statusChange", entry.getStatus().toString());
+ String css = getLozengeClass(entry.getStatus(), false);
+ WicketUtils.setCssClass(status, css);
+ for (IBehavior b : status.getBehaviors()) {
+ if (b instanceof SimpleAttributeModifier) {
+ SimpleAttributeModifier sam = (SimpleAttributeModifier) b;
+ if ("class".equals(sam.getAttribute())) {
+ status.add(new SimpleAttributeModifier("class", "status-change " + sam.getValue()));
+ break;
+ }
+ }
+ }
+ frag.add(status);
+ addUserAttributions(frag, entry, avatarWidth);
+ addDateAttributions(frag, entry);
+ item.add(frag);
+ } else {
+ /*
+ * COMMENT
+ */
+ String comment = MarkdownUtils.transformGFM(app().settings(), entry.comment.text, repositoryName);
+ Fragment frag = new Fragment("entry", "commentFragment", this);
+ Label commentIcon = new Label("commentIcon");
+ if (entry.comment.src == CommentSource.Email) {
+ WicketUtils.setCssClass(commentIcon, "iconic-mail");
+ } else {
+ WicketUtils.setCssClass(commentIcon, "iconic-comment-alt2-stroke");
+ }
+ frag.add(commentIcon);
+ frag.add(new Label("comment", comment).setEscapeModelStrings(false));
+ addUserAttributions(frag, entry, avatarWidth);
+ addDateAttributions(frag, entry);
+ item.add(frag);
+ }
+ }
+ };
+ discussionFragment.add(discussionView);
+ add(discussionFragment);
+ }
+
+ /*
+ * ADD COMMENT PANEL
+ */
+ if (UserModel.ANONYMOUS.equals(user)
+ || !repository.isBare
+ || repository.isFrozen
+ || repository.isMirror) {
+
+ // prohibit comments for anonymous users, local working copy repos,
+ // frozen repos, and mirrors
+ add(new Label("newComment").setVisible(false));
+ } else {
+ // permit user to comment
+ Fragment newComment = new Fragment("newComment", "newCommentFragment", this);
+ GravatarImage img = new GravatarImage("newCommentAvatar", user.username, user.emailAddress,
+ "gravatar-round", avatarWidth, true);
+ newComment.add(img);
+ CommentPanel commentPanel = new CommentPanel("commentPanel", user, ticket, null, TicketsPage.class);
+ commentPanel.setRepository(repositoryName);
+ newComment.add(commentPanel);
+ add(newComment);
+ }
+
+
+ /*
+ * PATCHSET TAB
+ */
+ if (currentPatchset == null) {
+ // no patchset yet, show propose fragment
+ String repoUrl = getRepositoryUrl(user, repository);
+ Fragment changeIdFrag = new Fragment("patchset", "proposeFragment", this);
+ changeIdFrag.add(new Label("proposeInstructions", MarkdownUtils.transformMarkdown(getString("gb.proposeInstructions"))).setEscapeModelStrings(false));
+ changeIdFrag.add(new Label("ptWorkflow", MessageFormat.format(getString("gb.proposeWith"), "Barnum")));
+ changeIdFrag.add(new Label("ptWorkflowSteps", getProposeWorkflow("propose_pt.md", repoUrl, ticket.number)).setEscapeModelStrings(false));
+ changeIdFrag.add(new Label("gitWorkflow", MessageFormat.format(getString("gb.proposeWith"), "Git")));
+ changeIdFrag.add(new Label("gitWorkflowSteps", getProposeWorkflow("propose_git.md", repoUrl, ticket.number)).setEscapeModelStrings(false));
+ add(changeIdFrag);
+ } else {
+ // show current patchset
+ Fragment patchsetFrag = new Fragment("patchset", "patchsetFragment", this);
+ patchsetFrag.add(new Label("commitsInPatchset", MessageFormat.format(getString("gb.commitsInPatchsetN"), currentPatchset.number)));
+
+ // current revision
+ MarkupContainer panel = createPatchsetPanel("panel", repository, user);
+ patchsetFrag.add(panel);
+ addUserAttributions(patchsetFrag, currentRevision, avatarWidth);
+ addUserAttributions(panel, currentRevision, 0);
+ addDateAttributions(panel, currentRevision);
+
+ // commits
+ List<RevCommit> commits = JGitUtils.getRevLog(getRepository(), currentPatchset.base, currentPatchset.tip);
+ ListDataProvider<RevCommit> commitsDp = new ListDataProvider<RevCommit>(commits);
+ DataView<RevCommit> commitsView = new DataView<RevCommit>("commit", commitsDp) {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void populateItem(final Item<RevCommit> item) {
+ RevCommit commit = item.getModelObject();
+ PersonIdent author = commit.getAuthorIdent();
+ item.add(new GravatarImage("authorAvatar", author.getName(), author.getEmailAddress(), null, 16, false));
+ item.add(new Label("author", commit.getAuthorIdent().getName()));
+ item.add(new LinkPanel("commitId", null, getShortObjectId(commit.getName()),
+ CommitPage.class, WicketUtils.newObjectParameter(repositoryName, commit.getName()), true));
+ item.add(new LinkPanel("diff", "link", getString("gb.diff"), CommitDiffPage.class,
+ WicketUtils.newObjectParameter(repositoryName, commit.getName()), true));
+ item.add(new Label("title", StringUtils.trimString(commit.getShortMessage(), Constants.LEN_SHORTLOG_REFS)));
+ item.add(WicketUtils.createDateLabel("commitDate", JGitUtils.getCommitDate(commit), GitBlitWebSession
+ .get().getTimezone(), getTimeUtils(), false));
+ item.add(new DiffStatPanel("commitDiffStat", 0, 0, true));
+ }
+ };
+ patchsetFrag.add(commitsView);
+ add(patchsetFrag);
+ }
+
+
+ /*
+ * ACTIVITY TAB
+ */
+ Fragment revisionHistory = new Fragment("activity", "activityFragment", this);
+ List<Change> events = new ArrayList<Change>(ticket.changes);
+ Collections.sort(events);
+ Collections.reverse(events);
+ ListDataProvider<Change> eventsDp = new ListDataProvider<Change>(events);
+ DataView<Change> eventsView = new DataView<Change>("event", eventsDp) {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void populateItem(final Item<Change> item) {
+ Change event = item.getModelObject();
+
+ addUserAttributions(item, event, 16);
+
+ if (event.hasPatchset()) {
+ // patchset
+ Patchset patchset = event.patchset;
+ String what;
+ if (event.isStatusChange() && (Status.New == event.getStatus())) {
+ what = getString("gb.proposedThisChange");
+ } else if (patchset.rev == 1) {
+ what = MessageFormat.format(getString("gb.uploadedPatchsetN"), patchset.number);
+ } else {
+ if (patchset.added == 1) {
+ what = getString("gb.addedOneCommit");
+ } else {
+ what = MessageFormat.format(getString("gb.addedNCommits"), patchset.added);
+ }
+ }
+ item.add(new Label("what", what));
+
+ LinkPanel psr = new LinkPanel("patchsetRevision", null, patchset.number + "-" + patchset.rev,
+ ComparePage.class, WicketUtils.newRangeParameter(repositoryName, patchset.parent == null ? patchset.base : patchset.parent, patchset.tip), true);
+ WicketUtils.setHtmlTooltip(psr, patchset.toString());
+ item.add(psr);
+ String typeCss = getPatchsetTypeCss(patchset.type);
+ Label typeLabel = new Label("patchsetType", patchset.type.toString());
+ if (typeCss == null) {
+ typeLabel.setVisible(false);
+ } else {
+ WicketUtils.setCssClass(typeLabel, typeCss);
+ }
+ item.add(typeLabel);
+
+ // show commit diffstat
+ item.add(new DiffStatPanel("patchsetDiffStat", patchset.insertions, patchset.deletions, patchset.rev > 1));
+ } else if (event.hasComment()) {
+ // comment
+ item.add(new Label("what", getString("gb.commented")));
+ item.add(new Label("patchsetRevision").setVisible(false));
+ item.add(new Label("patchsetType").setVisible(false));
+ item.add(new Label("patchsetDiffStat").setVisible(false));
+ } else if (event.hasReview()) {
+ // review
+ String score;
+ switch (event.review.score) {
+ case approved:
+ score = "<span style='color:darkGreen'>" + getScoreDescription(event.review.score) + "</span>";
+ break;
+ case vetoed:
+ score = "<span style='color:darkRed'>" + getScoreDescription(event.review.score) + "</span>";
+ break;
+ default:
+ score = getScoreDescription(event.review.score);
+ }
+ item.add(new Label("what", MessageFormat.format(getString("gb.reviewedPatchsetRev"),
+ event.review.patchset, event.review.rev, score))
+ .setEscapeModelStrings(false));
+ item.add(new Label("patchsetRevision").setVisible(false));
+ item.add(new Label("patchsetType").setVisible(false));
+ item.add(new Label("patchsetDiffStat").setVisible(false));
+ } else {
+ // field change
+ item.add(new Label("patchsetRevision").setVisible(false));
+ item.add(new Label("patchsetType").setVisible(false));
+ item.add(new Label("patchsetDiffStat").setVisible(false));
+
+ String what = "";
+ if (event.isStatusChange()) {
+ switch (event.getStatus()) {
+ case New:
+ if (ticket.isProposal()) {
+ what = getString("gb.proposedThisChange");
+ } else {
+ what = getString("gb.createdThisTicket");
+ }
+ break;
+ default:
+ break;
+ }
+ }
+ item.add(new Label("what", what).setVisible(what.length() > 0));
+ }
+
+ addDateAttributions(item, event);
+
+ if (event.hasFieldChanges()) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("<table class=\"summary\"><tbody>");
+ for (Map.Entry<Field, String> entry : event.fields.entrySet()) {
+ String value;
+ switch (entry.getKey()) {
+ case body:
+ String body = entry.getValue();
+ if (event.isStatusChange() && Status.New == event.getStatus() && StringUtils.isEmpty(body)) {
+ // ignore initial empty description
+ continue;
+ }
+ // trim body changes
+ if (StringUtils.isEmpty(body)) {
+ value = "<i>" + ESC_NIL + "</i>";
+ } else {
+ value = StringUtils.trimString(body, Constants.LEN_SHORTLOG_REFS);
+ }
+ break;
+ case status:
+ // special handling for status
+ Status status = event.getStatus();
+ String css = getLozengeClass(status, true);
+ value = String.format("<span class=\"%1$s\">%2$s</span>", css, status.toString());
+ break;
+ default:
+ value = StringUtils.isEmpty(entry.getValue()) ? ("<i>" + ESC_NIL + "</i>") : StringUtils.escapeForHtml(entry.getValue(), false);
+ break;
+ }
+ sb.append("<tr><th style=\"width:70px;\">");
+ sb.append(entry.getKey().name());
+ sb.append("</th><td>");
+ sb.append(value);
+ sb.append("</td></tr>");
+ }
+ sb.append("</tbody></table>");
+ item.add(new Label("fields", sb.toString()).setEscapeModelStrings(false));
+ } else {
+ item.add(new Label("fields").setVisible(false));
+ }
+ }
+ };
+ revisionHistory.add(eventsView);
+ add(revisionHistory);
+ }
+
+ protected void addUserAttributions(MarkupContainer container, Change entry, int avatarSize) {
+ UserModel commenter = app().users().getUserModel(entry.author);
+ if (commenter == null) {
+ // unknown user
+ container.add(new GravatarImage("changeAvatar", entry.author,
+ entry.author, null, avatarSize, false).setVisible(avatarSize > 0));
+ container.add(new Label("changeAuthor", entry.author.toLowerCase()));
+ } else {
+ // known user
+ container.add(new GravatarImage("changeAvatar", commenter.getDisplayName(),
+ commenter.emailAddress, avatarSize > 24 ? "gravatar-round" : null,
+ avatarSize, true).setVisible(avatarSize > 0));
+ container.add(new LinkPanel("changeAuthor", null, commenter.getDisplayName(),
+ UserPage.class, WicketUtils.newUsernameParameter(commenter.username)));
+ }
+ }
+
+ protected void addDateAttributions(MarkupContainer container, Change entry) {
+ container.add(WicketUtils.createDateLabel("changeDate", entry.date, GitBlitWebSession
+ .get().getTimezone(), getTimeUtils(), false));
+
+ // set the id attribute
+ if (entry.hasComment()) {
+ container.setOutputMarkupId(true);
+ container.add(new AttributeModifier("id", Model.of(entry.getId())));
+ ExternalLink link = new ExternalLink("changeLink", "#" + entry.getId());
+ container.add(link);
+ } else {
+ container.add(new Label("changeLink").setVisible(false));
+ }
+ }
+
+ protected String getProposeWorkflow(String resource, String url, long ticketId) {
+ String md = readResource(resource);
+ md = md.replace("${url}", url);
+ md = md.replace("${repo}", StringUtils.getLastPathElement(StringUtils.stripDotGit(repositoryName)));
+ md = md.replace("${ticketId}", "" + ticketId);
+ md = md.replace("${patchset}", "" + 1);
+ md = md.replace("${reviewBranch}", Repository.shortenRefName(PatchsetCommand.getTicketBranch(ticketId)));
+ md = md.replace("${integrationBranch}", Repository.shortenRefName(getRepositoryModel().HEAD));
+ return MarkdownUtils.transformMarkdown(md);
+ }
+
+ protected Fragment createPatchsetPanel(String wicketId, RepositoryModel repository, UserModel user) {
+ final Patchset currentPatchset = ticket.getCurrentPatchset();
+ List<Patchset> patchsets = new ArrayList<Patchset>(ticket.getPatchsetRevisions(currentPatchset.number));
+ patchsets.remove(currentPatchset);
+ Collections.reverse(patchsets);
+
+ Fragment panel = new Fragment(wicketId, "collapsiblePatchsetFragment", this);
+
+ // patchset header
+ String ps = "<b>" + currentPatchset.number + "</b>";
+ if (currentPatchset.rev == 1) {
+ panel.add(new Label("uploadedWhat", MessageFormat.format(getString("gb.uploadedPatchsetN"), ps)).setEscapeModelStrings(false));
+ } else {
+ String rev = "<b>" + currentPatchset.rev + "</b>";
+ panel.add(new Label("uploadedWhat", MessageFormat.format(getString("gb.uploadedPatchsetNRevisionN"), ps, rev)).setEscapeModelStrings(false));
+ }
+ panel.add(new LinkPanel("patchId", null, "rev " + currentPatchset.rev,
+ CommitPage.class, WicketUtils.newObjectParameter(repositoryName, currentPatchset.tip), true));
+
+ // compare menu
+ panel.add(new LinkPanel("compareMergeBase", null, getString("gb.compareToMergeBase"),
+ ComparePage.class, WicketUtils.newRangeParameter(repositoryName, currentPatchset.base, currentPatchset.tip), true));
+
+ ListDataProvider<Patchset> compareMenuDp = new ListDataProvider<Patchset>(patchsets);
+ DataView<Patchset> compareMenu = new DataView<Patchset>("comparePatch", compareMenuDp) {
+ private static final long serialVersionUID = 1L;
+ @Override
+ public void populateItem(final Item<Patchset> item) {
+ Patchset patchset = item.getModelObject();
+ LinkPanel link = new LinkPanel("compareLink", null,
+ MessageFormat.format(getString("gb.compareToN"), patchset.number + "-" + patchset.rev),
+ ComparePage.class, WicketUtils.newRangeParameter(getRepositoryModel().name,
+ patchset.tip, currentPatchset.tip), true);
+ item.add(link);
+
+ }
+ };
+ panel.add(compareMenu);
+
+
+ // reviews
+ List<Change> reviews = ticket.getReviews(currentPatchset);
+ ListDataProvider<Change> reviewsDp = new ListDataProvider<Change>(reviews);
+ DataView<Change> reviewsView = new DataView<Change>("reviews", reviewsDp) {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void populateItem(final Item<Change> item) {
+ Change change = item.getModelObject();
+ final String username = change.author;
+ UserModel user = app().users().getUserModel(username);
+ if (user == null) {
+ item.add(new Label("reviewer", username));
+ } else {
+ item.add(new LinkPanel("reviewer", null, user.getDisplayName(),
+ UserPage.class, WicketUtils.newUsernameParameter(username)));
+ }
+
+ // indicate review score
+ Review review = change.review;
+ Label scoreLabel = new Label("score");
+ String scoreClass = getScoreClass(review.score);
+ String tooltip = getScoreDescription(review.score);
+ WicketUtils.setCssClass(scoreLabel, scoreClass);
+ if (!StringUtils.isEmpty(tooltip)) {
+ WicketUtils.setHtmlTooltip(scoreLabel, tooltip);
+ }
+ item.add(scoreLabel);
+ }
+ };
+ panel.add(reviewsView);
+
+
+ if (ticket.isOpen() && user.canReviewPatchset(repository)) {
+ // can only review open tickets
+ Review myReview = null;
+ for (Change change : ticket.getReviews(currentPatchset)) {
+ if (change.author.equals(user.username)) {
+ myReview = change.review;
+ }
+ }
+
+ // user can review, add review controls
+ Fragment reviewControls = new Fragment("reviewControls", "reviewControlsFragment", this);
+
+ // show "approve" button if no review OR not current score
+ if (user.canApprovePatchset(repository) && (myReview == null || Score.approved != myReview.score)) {
+ reviewControls.add(createReviewLink("approveLink", Score.approved));
+ } else {
+ reviewControls.add(new Label("approveLink").setVisible(false));
+ }
+
+ // show "looks good" button if no review OR not current score
+ if (myReview == null || Score.looks_good != myReview.score) {
+ reviewControls.add(createReviewLink("looksGoodLink", Score.looks_good));
+ } else {
+ reviewControls.add(new Label("looksGoodLink").setVisible(false));
+ }
+
+ // show "needs improvement" button if no review OR not current score
+ if (myReview == null || Score.needs_improvement != myReview.score) {
+ reviewControls.add(createReviewLink("needsImprovementLink", Score.needs_improvement));
+ } else {
+ reviewControls.add(new Label("needsImprovementLink").setVisible(false));
+ }
+
+ // show "veto" button if no review OR not current score
+ if (user.canVetoPatchset(repository) && (myReview == null || Score.vetoed != myReview.score)) {
+ reviewControls.add(createReviewLink("vetoLink", Score.vetoed));
+ } else {
+ reviewControls.add(new Label("vetoLink").setVisible(false));
+ }
+ panel.add(reviewControls);
+ } else {
+ // user can not review
+ panel.add(new Label("reviewControls").setVisible(false));
+ }
+
+ String insertions = MessageFormat.format("<span style=\"color:darkGreen;font-weight:bold;\">+{0}</span>", ticket.insertions);
+ String deletions = MessageFormat.format("<span style=\"color:darkRed;font-weight:bold;\">-{0}</span>", ticket.deletions);
+ panel.add(new Label("patchsetStat", MessageFormat.format(StringUtils.escapeForHtml(getString("gb.diffStat"), false),
+ insertions, deletions)).setEscapeModelStrings(false));
+
+ // changed paths list
+ List<PathChangeModel> paths = JGitUtils.getFilesInRange(getRepository(), currentPatchset.base, currentPatchset.tip);
+ ListDataProvider<PathChangeModel> pathsDp = new ListDataProvider<PathChangeModel>(paths);
+ DataView<PathChangeModel> pathsView = new DataView<PathChangeModel>("changedPath", pathsDp) {
+ private static final long serialVersionUID = 1L;
+ int counter;
+
+ @Override
+ public void populateItem(final Item<PathChangeModel> item) {
+ final PathChangeModel entry = item.getModelObject();
+ Label changeType = new Label("changeType", "");
+ WicketUtils.setChangeTypeCssClass(changeType, entry.changeType);
+ setChangeTypeTooltip(changeType, entry.changeType);
+ item.add(changeType);
+ item.add(new DiffStatPanel("diffStat", entry.insertions, entry.deletions, true));
+
+ boolean hasSubmodule = false;
+ String submodulePath = null;
+ if (entry.isTree()) {
+ // tree
+ item.add(new LinkPanel("pathName", null, entry.path, TreePage.class,
+ WicketUtils
+ .newPathParameter(repositoryName, currentPatchset.tip, entry.path), true));
+ item.add(new Label("diffStat").setVisible(false));
+ } else if (entry.isSubmodule()) {
+ // submodule
+ String submoduleId = entry.objectId;
+ SubmoduleModel submodule = getSubmodule(entry.path);
+ submodulePath = submodule.gitblitPath;
+ hasSubmodule = submodule.hasSubmodule;
+
+ item.add(new LinkPanel("pathName", "list", entry.path + " @ " +
+ getShortObjectId(submoduleId), TreePage.class,
+ WicketUtils.newPathParameter(submodulePath, submoduleId, ""), true).setEnabled(hasSubmodule));
+ item.add(new Label("diffStat").setVisible(false));
+ } else {
+ // blob
+ String displayPath = entry.path;
+ String path = entry.path;
+ if (entry.isSymlink()) {
+ RevCommit commit = JGitUtils.getCommit(getRepository(), Constants.R_TICKETS_PATCHSETS + ticket.number);
+ path = JGitUtils.getStringContent(getRepository(), commit.getTree(), path);
+ displayPath = entry.path + " -> " + path;
+ }
+
+ if (entry.changeType.equals(ChangeType.ADD)) {
+ // add show view
+ item.add(new LinkPanel("pathName", "list", displayPath, BlobPage.class,
+ WicketUtils.newPathParameter(repositoryName, currentPatchset.tip, path), true));
+ } else if (entry.changeType.equals(ChangeType.DELETE)) {
+ // delete, show label
+ item.add(new Label("pathName", displayPath));
+ } else {
+ // mod, show diff
+ item.add(new LinkPanel("pathName", "list", displayPath, BlobDiffPage.class,
+ WicketUtils.newPathParameter(repositoryName, currentPatchset.tip, path), true));
+ }
+ }
+
+ // quick links
+ if (entry.isSubmodule()) {
+ // submodule
+ item.add(setNewTarget(new BookmarkablePageLink<Void>("diff", BlobDiffPage.class, WicketUtils
+ .newPathParameter(repositoryName, entry.commitId, entry.path)))
+ .setEnabled(!entry.changeType.equals(ChangeType.ADD)));
+ item.add(new BookmarkablePageLink<Void>("view", CommitPage.class, WicketUtils
+ .newObjectParameter(submodulePath, entry.objectId)).setEnabled(hasSubmodule));
+ } else {
+ // tree or blob
+ item.add(setNewTarget(new BookmarkablePageLink<Void>("diff", BlobDiffPage.class, WicketUtils
+ .newBlobDiffParameter(repositoryName, currentPatchset.base, currentPatchset.tip, entry.path)))
+ .setEnabled(!entry.changeType.equals(ChangeType.ADD)
+ && !entry.changeType.equals(ChangeType.DELETE)));
+ item.add(setNewTarget(new BookmarkablePageLink<Void>("view", BlobPage.class, WicketUtils
+ .newPathParameter(repositoryName, currentPatchset.tip, entry.path)))
+ .setEnabled(!entry.changeType.equals(ChangeType.DELETE)));
+ }
+
+ WicketUtils.setAlternatingBackground(item, counter);
+ counter++;
+ }
+ };
+ panel.add(pathsView);
+
+ addPtReviewInstructions(user, repository, panel);
+ addGitReviewInstructions(user, repository, panel);
+ panel.add(createMergePanel(user, repository));
+
+ return panel;
+ }
+
+ protected IconAjaxLink<String> createReviewLink(String wicketId, final Score score) {
+ return new IconAjaxLink<String>(wicketId, getScoreClass(score), Model.of(getScoreDescription(score))) {
+
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void onClick(AjaxRequestTarget target) {
+ review(score);
+ }
+ };
+ }
+
+ protected String getScoreClass(Score score) {
+ switch (score) {
+ case vetoed:
+ return "fa fa-exclamation-circle";
+ case needs_improvement:
+ return "fa fa-thumbs-o-down";
+ case looks_good:
+ return "fa fa-thumbs-o-up";
+ case approved:
+ return "fa fa-check-circle";
+ case not_reviewed:
+ default:
+ return "fa fa-minus-circle";
+ }
+ }
+
+ protected String getScoreDescription(Score score) {
+ String description;
+ switch (score) {
+ case vetoed:
+ description = getString("gb.veto");
+ break;
+ case needs_improvement:
+ description = getString("gb.needsImprovement");
+ break;
+ case looks_good:
+ description = getString("gb.looksGood");
+ break;
+ case approved:
+ description = getString("gb.approve");
+ break;
+ case not_reviewed:
+ default:
+ description = getString("gb.hasNotReviewed");
+ }
+ return String.format("%1$s (%2$+d)", description, score.getValue());
+ }
+
+ protected void review(Score score) {
+ UserModel user = GitBlitWebSession.get().getUser();
+ Patchset ps = ticket.getCurrentPatchset();
+ Change change = new Change(user.username);
+ change.review(ps, score, !ticket.isReviewer(user.username));
+ if (!ticket.isWatching(user.username)) {
+ change.watch(user.username);
+ }
+ TicketModel updatedTicket = app().tickets().updateTicket(getRepositoryModel(), ticket.number, change);
+ app().tickets().createNotifier().sendMailing(updatedTicket);
+ setResponsePage(TicketsPage.class, getPageParameters());
+ }
+
+ protected <X extends MarkupContainer> X setNewTarget(X x) {
+ x.add(new SimpleAttributeModifier("target", "_blank"));
+ return x;
+ }
+
+ protected void addGitReviewInstructions(UserModel user, RepositoryModel repository, MarkupContainer panel) {
+ String repoUrl = getRepositoryUrl(user, repository);
+
+ panel.add(new Label("gitStep1", MessageFormat.format(getString("gb.stepN"), 1)));
+ panel.add(new Label("gitStep2", MessageFormat.format(getString("gb.stepN"), 2)));
+
+ String ticketBranch = Repository.shortenRefName(PatchsetCommand.getTicketBranch(ticket.number));
+ String reviewBranch = PatchsetCommand.getReviewBranch(ticket.number);
+
+ String step1 = MessageFormat.format("git fetch {0} {1}", repoUrl, ticketBranch);
+ String step2 = MessageFormat.format("git checkout -B {0} FETCH_HEAD", reviewBranch);
+
+ panel.add(new Label("gitPreStep1", step1));
+ panel.add(new Label("gitPreStep2", step2));
+
+ panel.add(createCopyFragment("gitCopyStep1", step1.replace("\n", " && ")));
+ panel.add(createCopyFragment("gitCopyStep2", step2.replace("\n", " && ")));
+ }
+
+ protected void addPtReviewInstructions(UserModel user, RepositoryModel repository, MarkupContainer panel) {
+ String step1 = MessageFormat.format("pt checkout {0,number,0}", ticket.number);
+ panel.add(new Label("ptPreStep", step1));
+ panel.add(createCopyFragment("ptCopyStep", step1));
+ }
+
+ /**
+ * Adds a merge panel for the patchset to the markup container. The panel
+ * may just a message if the patchset can not be merged.
+ *
+ * @param c
+ * @param user
+ * @param repository
+ */
+ protected Component createMergePanel(UserModel user, RepositoryModel repository) {
+ Patchset patchset = ticket.getCurrentPatchset();
+ if (patchset == null) {
+ // no patchset to merge
+ return new Label("mergePanel");
+ }
+
+ boolean allowMerge;
+ if (repository.requireApproval) {
+ // rpeository requires approval
+ allowMerge = ticket.isOpen() && ticket.isApproved(patchset);
+ } else {
+ // vetos are binding
+ allowMerge = ticket.isOpen() && !ticket.isVetoed(patchset);
+ }
+
+ MergeStatus mergeStatus = JGitUtils.canMerge(getRepository(), patchset.tip, ticket.mergeTo);
+ if (allowMerge) {
+ if (MergeStatus.MERGEABLE == mergeStatus) {
+ // patchset can be cleanly merged to integration branch OR has already been merged
+ Fragment mergePanel = new Fragment("mergePanel", "mergeableFragment", this);
+ mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetMergeable"), ticket.mergeTo)));
+ if (user.canPush(repository)) {
+ // user can merge locally
+ SimpleAjaxLink<String> mergeButton = new SimpleAjaxLink<String>("mergeButton", Model.of(getString("gb.merge"))) {
+
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void onClick(AjaxRequestTarget target) {
+
+ // ensure the patchset is still current AND not vetoed
+ Patchset patchset = ticket.getCurrentPatchset();
+ final TicketModel refreshedTicket = app().tickets().getTicket(getRepositoryModel(), ticket.number);
+ if (patchset.equals(refreshedTicket.getCurrentPatchset())) {
+ // patchset is current, check for recent veto
+ if (!refreshedTicket.isVetoed(patchset)) {
+ // patchset is not vetoed
+
+ // execute the merge using the ticket service
+ app().tickets().exec(new Runnable() {
+ @Override
+ public void run() {
+ PatchsetReceivePack rp = new PatchsetReceivePack(
+ app().gitblit(),
+ getRepository(),
+ getRepositoryModel(),
+ GitBlitWebSession.get().getUser());
+ MergeStatus result = rp.merge(refreshedTicket);
+ if (MergeStatus.MERGED == result) {
+ // notify participants and watchers
+ rp.sendAll();
+ } else {
+ // merge failure
+ String msg = MessageFormat.format("Failed to merge ticket {0,number,0}: {1}", ticket.number, result.name());
+ logger.error(msg);
+ GitBlitWebSession.get().cacheErrorMessage(msg);
+ }
+ }
+ });
+ } else {
+ // vetoed patchset
+ String msg = MessageFormat.format("Can not merge ticket {0,number,0}, patchset {1,number,0} has been vetoed!",
+ ticket.number, patchset.number);
+ GitBlitWebSession.get().cacheErrorMessage(msg);
+ logger.error(msg);
+ }
+ } else {
+ // not current patchset
+ String msg = MessageFormat.format("Can not merge ticket {0,number,0}, the patchset has been updated!", ticket.number);
+ GitBlitWebSession.get().cacheErrorMessage(msg);
+ logger.error(msg);
+ }
+
+ setResponsePage(TicketsPage.class, getPageParameters());
+ }
+ };
+ mergePanel.add(mergeButton);
+ Component instructions = getMergeInstructions(user, repository, "mergeMore", "gb.patchsetMergeableMore");
+ mergePanel.add(instructions);
+ } else {
+ mergePanel.add(new Label("mergeButton").setVisible(false));
+ mergePanel.add(new Label("mergeMore").setVisible(false));
+ }
+ return mergePanel;
+ } else if (MergeStatus.ALREADY_MERGED == mergeStatus) {
+ // patchset already merged
+ Fragment mergePanel = new Fragment("mergePanel", "alreadyMergedFragment", this);
+ mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetAlreadyMerged"), ticket.mergeTo)));
+ return mergePanel;
+ } else {
+ // patchset can not be cleanly merged
+ Fragment mergePanel = new Fragment("mergePanel", "notMergeableFragment", this);
+ mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetNotMergeable"), ticket.mergeTo)));
+ if (user.canPush(repository)) {
+ // user can merge locally
+ Component instructions = getMergeInstructions(user, repository, "mergeMore", "gb.patchsetNotMergeableMore");
+ mergePanel.add(instructions);
+ } else {
+ mergePanel.add(new Label("mergeMore").setVisible(false));
+ }
+ return mergePanel;
+ }
+ } else {
+ // merge not allowed
+ if (MergeStatus.ALREADY_MERGED == mergeStatus) {
+ // patchset already merged
+ Fragment mergePanel = new Fragment("mergePanel", "alreadyMergedFragment", this);
+ mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetAlreadyMerged"), ticket.mergeTo)));
+ return mergePanel;
+ } else if (ticket.isVetoed(patchset)) {
+ // patchset has been vetoed
+ Fragment mergePanel = new Fragment("mergePanel", "vetoedFragment", this);
+ mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetNotMergeable"), ticket.mergeTo)));
+ return mergePanel;
+ } else if (repository.requireApproval) {
+ // patchset has been not been approved for merge
+ Fragment mergePanel = new Fragment("mergePanel", "notApprovedFragment", this);
+ mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetNotApproved"), ticket.mergeTo)));
+ mergePanel.add(new Label("mergeMore", MessageFormat.format(getString("gb.patchsetNotApprovedMore"), ticket.mergeTo)));
+ return mergePanel;
+ } else {
+ // other case
+ return new Label("mergePanel");
+ }
+ }
+ }
+
+ protected Component getMergeInstructions(UserModel user, RepositoryModel repository, String markupId, String infoKey) {
+ Fragment cmd = new Fragment(markupId, "commandlineMergeFragment", this);
+ cmd.add(new Label("instructions", MessageFormat.format(getString(infoKey), ticket.mergeTo)));
+ String repoUrl = getRepositoryUrl(user, repository);
+
+ // git instructions
+ cmd.add(new Label("mergeStep1", MessageFormat.format(getString("gb.stepN"), 1)));
+ cmd.add(new Label("mergeStep2", MessageFormat.format(getString("gb.stepN"), 2)));
+ cmd.add(new Label("mergeStep3", MessageFormat.format(getString("gb.stepN"), 3)));
+
+ String ticketBranch = Repository.shortenRefName(PatchsetCommand.getTicketBranch(ticket.number));
+ String reviewBranch = PatchsetCommand.getReviewBranch(ticket.number);
+
+ String step1 = MessageFormat.format("git checkout -B {0} {1}", reviewBranch, ticket.mergeTo);
+ String step2 = MessageFormat.format("git pull {0} {1}", repoUrl, ticketBranch);
+ String step3 = MessageFormat.format("git checkout {0}\ngit merge {1}\ngit push origin {0}", ticket.mergeTo, reviewBranch);
+
+ cmd.add(new Label("mergePreStep1", step1));
+ cmd.add(new Label("mergePreStep2", step2));
+ cmd.add(new Label("mergePreStep3", step3));
+
+ cmd.add(createCopyFragment("mergeCopyStep1", step1.replace("\n", " && ")));
+ cmd.add(createCopyFragment("mergeCopyStep2", step2.replace("\n", " && ")));
+ cmd.add(createCopyFragment("mergeCopyStep3", step3.replace("\n", " && ")));
+
+ // pt instructions
+ String ptStep = MessageFormat.format("pt pull {0,number,0}", ticket.number);
+ cmd.add(new Label("ptMergeStep", ptStep));
+ cmd.add(createCopyFragment("ptMergeCopyStep", step1.replace("\n", " && ")));
+ return cmd;
+ }
+
+ /**
+ * Returns the primary repository url
+ *
+ * @param user
+ * @param repository
+ * @return the primary repository url
+ */
+ protected String getRepositoryUrl(UserModel user, RepositoryModel repository) {
+ HttpServletRequest req = ((WebRequest) getRequest()).getHttpServletRequest();
+ String primaryurl = app().gitblit().getRepositoryUrls(req, user, repository).get(0).url;
+ String url = primaryurl;
+ try {
+ url = new URIish(primaryurl).setUser(null).toString();
+ } catch (Exception e) {
+ }
+ return url;
+ }
+
+ /**
+ * Returns the ticket (if any) that this commit references.
+ *
+ * @param commit
+ * @return null or a ticket
+ */
+ protected TicketModel getTicket(RevCommit commit) {
+ try {
+ Map<String, Ref> refs = getRepository().getRefDatabase().getRefs(Constants.R_TICKETS_PATCHSETS);
+ for (Map.Entry<String, Ref> entry : refs.entrySet()) {
+ if (entry.getValue().getObjectId().equals(commit.getId())) {
+ long id = PatchsetCommand.getTicketNumber(entry.getKey());
+ TicketModel ticket = app().tickets().getTicket(getRepositoryModel(), id);
+ return ticket;
+ }
+ }
+ } catch (Exception e) {
+ logger().error("failed to determine ticket from ref", e);
+ }
+ return null;
+ }
+
+ protected String getPatchsetTypeCss(PatchsetType type) {
+ String typeCss;
+ switch (type) {
+ case Rebase:
+ case Rebase_Squash:
+ typeCss = getLozengeClass(Status.Declined, false);
+ break;
+ case Squash:
+ case Amend:
+ typeCss = getLozengeClass(Status.On_Hold, false);
+ break;
+ case Proposal:
+ typeCss = getLozengeClass(Status.New, false);
+ break;
+ case FastForward:
+ default:
+ typeCss = null;
+ break;
+ }
+ return typeCss;
+ }
+
+ @Override
+ protected String getPageName() {
+ return getString("gb.ticket");
+ }
+
+ @Override
+ protected Class<? extends BasePage> getRepoNavPageClass() {
+ return TicketsPage.class;
+ }
+
+ @Override
+ protected String getPageTitle(String repositoryName) {
+ return "#" + ticket.number + " - " + ticket.title;
+ }
+
+ protected Fragment createCopyFragment(String wicketId, String text) {
+ if (app().settings().getBoolean(Keys.web.allowFlashCopyToClipboard, true)) {
+ // clippy: flash-based copy & paste
+ Fragment copyFragment = new Fragment(wicketId, "clippyPanel", this);
+ String baseUrl = WicketUtils.getGitblitURL(getRequest());
+ ShockWaveComponent clippy = new ShockWaveComponent("clippy", baseUrl + "/clippy.swf");
+ clippy.setValue("flashVars", "text=" + StringUtils.encodeURL(text));
+ copyFragment.add(clippy);
+ return copyFragment;
+ } else {
+ // javascript: manual copy & paste with modal browser prompt dialog
+ Fragment copyFragment = new Fragment(wicketId, "jsPanel", this);
+ ContextImage img = WicketUtils.newImage("copyIcon", "clippy.png");
+ img.add(new JavascriptTextPrompt("onclick", "Copy to Clipboard (Ctrl+C, Enter)", text));
+ copyFragment.add(img);
+ return copyFragment;
+ }
+ }
+}
diff --git a/src/main/java/com/gitblit/wicket/pages/TicketsPage.html b/src/main/java/com/gitblit/wicket/pages/TicketsPage.html new file mode 100644 index 00000000..90544908 --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/TicketsPage.html @@ -0,0 +1,215 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:wicket="http://wicket.apache.org/dtds.data/wicket-xhtml1.3-strict.dtd"
+ xml:lang="en"
+ lang="en">
+
+<body>
+<wicket:extend>
+
+ <!-- search tickets form -->
+ <div class="hidden-phone pull-right">
+ <form class="form-search" style="margin: 0px;" wicket:id="ticketSearchForm">
+ <div class="input-append">
+ <input type="text" class="search-query" style="width: 170px;border-radius: 14px 0 0 14px; padding-left: 14px;" id="ticketSearchBox" wicket:id="ticketSearchBox" value=""/>
+ <button class="btn" style="border-radius: 0 14px 14px 0px;margin-left:-5px;" type="submit"><i class="icon-search"></i></button>
+ </div>
+ </form>
+ </div>
+
+ <ul class="nav nav-tabs">
+ <li class="active"><a data-toggle="tab" href="#tickets"><i style="color:#888;"class="fa fa-ticket"></i> <wicket:message key="gb.tickets"></wicket:message></a></li>
+ <li><a data-toggle="tab" href="#milestones"><i style="color:#888;"class="fa fa-bullseye"></i> <wicket:message key="gb.milestones"></wicket:message></a></li>
+ </ul>
+ <div class="tab-content">
+ <div class="tab-pane active" id="tickets">
+ <div class="row" style="min-height:400px;" >
+
+ <!-- query controls -->
+ <div class="span3">
+ <div wicket:id="milestonePanel"></div>
+ <div class="hidden-phone">
+ <ul class="nav nav-list">
+ <li class="nav-header"><wicket:message key="gb.queries"></wicket:message></li>
+ <li><a wicket:id="changesQuery"><i class="fa fa-code-fork"></i> <wicket:message key="gb.proposalTickets"></wicket:message></a></li>
+ <li><a wicket:id="bugsQuery"><i class="fa fa-bug"></i> <wicket:message key="gb.bugTickets"></wicket:message></a></li>
+ <li><a wicket:id="enhancementsQuery"><i class="fa fa-magic"></i> <wicket:message key="gb.enhancementTickets"></wicket:message></a></li>
+ <li><a wicket:id="tasksQuery"><i class="fa fa-ticket"></i> <wicket:message key="gb.taskTickets"></wicket:message></a></li>
+ <li><a wicket:id="questionsQuery"><i class="fa fa-question"></i> <wicket:message key="gb.questionTickets"></wicket:message></a></li>
+ <li wicket:id="userDivider" class="divider"></li>
+ <li><a wicket:id="createdQuery"><i class="fa fa-user"></i> <wicket:message key="gb.yourCreatedTickets"></wicket:message></a></li>
+ <li><a wicket:id="watchedQuery"><i class="fa fa-eye"></i> <wicket:message key="gb.yourWatchedTickets"></wicket:message></a></li>
+ <li><a wicket:id="mentionsQuery"><i class="fa fa-comment"></i> <wicket:message key="gb.mentionsMeTickets"></wicket:message></a></li>
+ <li class="divider"></li>
+ <li><a wicket:id="resetQuery"><i class="fa fa-bolt"></i> <wicket:message key="gb.reset"></wicket:message></a></li>
+ </ul>
+ </div>
+ <div wicket:id="dynamicQueries" class="hidden-phone"></div>
+ </div>
+
+ <!-- tickets -->
+ <div class="span9">
+ <div class="btn-toolbar" style="margin-top: 0px;">
+ <div class="btn-group">
+ <a class="btn dropdown-toggle" data-toggle="dropdown" href="#"><wicket:message key="gb.status"></wicket:message>: <span style="font-weight:bold;" wicket:id="selectedStatii"></span> <span class="caret"></span></a>
+ <ul class="dropdown-menu">
+ <li><a wicket:id="openTickets">open</a></li>
+ <li><a wicket:id="closedTickets">closed</a></li>
+ <li><a wicket:id="allTickets">all</a></li>
+ <li class="divider"></li>
+ <li wicket:id="statii"><span wicket:id="statusLink"></span></li>
+ </ul>
+ </div>
+
+ <div class="btn-group hidden-phone">
+ <a class="btn dropdown-toggle" data-toggle="dropdown" href="#"><i class="fa fa-user"></i> <wicket:message key="gb.responsible"></wicket:message>: <span style="font-weight:bold;" wicket:id="currentResponsible"></span> <span class="caret"></span></a>
+ <ul class="dropdown-menu">
+ <li wicket:id="responsible"><span wicket:id="responsibleLink"></span></li>
+ <li class="divider"></li>
+ <li><a wicket:id="resetResponsible"><i class="fa fa-bolt"></i> <wicket:message key="gb.reset"></wicket:message></a></li>
+ </ul>
+ </div>
+
+ <div class="btn-group">
+ <a class="btn dropdown-toggle" data-toggle="dropdown" href="#"><i class="fa fa-sort"></i> <wicket:message key="gb.sort"></wicket:message>: <span style="font-weight:bold;" wicket:id="currentSort"></span> <span class="caret"></span></a>
+ <ul class="dropdown-menu">
+ <li wicket:id="sort"><span wicket:id="sortLink"></span></li>
+ </ul>
+ </div>
+
+ <div class="btn-group pull-right">
+ <div class="pagination pagination-right pagination-small">
+ <ul>
+ <li><a wicket:id="prevLink"><i class="fa fa-angle-double-left"></i></a></li>
+ <li wicket:id="pageLink"><span wicket:id="page"></span></li>
+ <li><a wicket:id="nextLink"><i class="fa fa-angle-double-right"></i></a></li>
+ </ul>
+ </div>
+ </div>
+ </div>
+
+
+ <table class="table tickets">
+ <tbody>
+ <tr wicket:id="ticket">
+ <td class="ticket-list-icon">
+ <i wicket:id="state"></i>
+ </td>
+ <td>
+ <span wicket:id="title">[title]</span> <span wicket:id="labels" style="font-weight: normal;color:white;"><span class="label" wicket:id="label"></span></span>
+ <div class="ticket-list-details">
+ <span style="padding-right: 10px;" class="hidden-phone">
+ <wicket:message key="gb.createdBy"></wicket:message>
+ <span style="padding: 0px 2px" wicket:id="createdBy">[createdBy]</span> <span class="date" wicket:id="createDate">[create date]</span>
+ </span>
+ <span wicket:id="indicators" style="white-space:nowrap;"><i wicket:id="icon"></i> <span style="padding-right:10px;" wicket:id="count"></span></span>
+ </div>
+ <div class="hidden-phone" wicket:id="updated"></div>
+ </td>
+ <td class="ticket-list-state">
+ <span class="badge badge-info" wicket:id="votes"></span>
+ </td>
+ <td class="hidden-phone ticket-list-state">
+ <i wicket:message="title:gb.watching" style="color:#888;" class="fa fa-eye" wicket:id="watching"></i>
+ </td>
+ <td class="ticket-list-state">
+ <div wicket:id="status"></div>
+ </td>
+ <td class="indicators">
+ <div>
+ <b>#<span wicket:id="id">[id]</span></b>
+ </div>
+ <div wicket:id="responsible"></div>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+ <div class="btn-group pull-right">
+ <div class="pagination pagination-right pagination-small">
+ <ul>
+ <li><a wicket:id="prevLink"><i class="fa fa-angle-double-left"></i></a></li>
+ <li wicket:id="pageLink"><span wicket:id="page"></span></li>
+ <li><a wicket:id="nextLink"><i class="fa fa-angle-double-right"></i></a></li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="tab-pane" id="milestones">
+ <div class="row">
+ <div class="span9" wicket:id="milestoneList">
+ <h3><span wicket:id="milestoneName"></span> <small><span wicket:id="milestoneState"></span></small></h3>
+ <span wicket:id="milestoneDue"></span>
+ </div>
+ </div>
+ </div>
+</div>
+
+<wicket:fragment wicket:id="noMilestoneFragment">
+<table style="width: 100%;padding-bottom: 5px;">
+<tbody>
+<tr>
+ <td style="color:#888;"><wicket:message key="gb.noMilestoneSelected"></wicket:message></td>
+ <td><div wicket:id="milestoneDropdown"></div></td>
+</tr>
+</tbody>
+</table>
+</wicket:fragment>
+
+<wicket:fragment wicket:id="milestoneProgressFragment">
+<table style="width: 100%;padding-bottom: 5px;">
+<tbody>
+<tr>
+ <td style="color:#888;">
+ <div><i style="color:#888;"class="fa fa-bullseye"></i> <span style="font-weight:bold;" wicket:id="currentMilestone"></span></div>
+ <div><i style="color:#888;"class="fa fa-calendar"></i> <span style="font-weight:bold;" wicket:id="currentDueDate"></span></div>
+ </td>
+ <td>
+ <div wicket:id="milestoneDropdown"></div>
+ </td>
+</tr>
+</tbody>
+</table>
+<div style="clear:both;padding-bottom: 10px;">
+ <div style="margin-bottom: 5px;" class="progress progress-success">
+ <div class="bar" wicket:id="progress"></div>
+ </div>
+ <div class="milestoneOverview">
+ <span wicket:id="openTickets" />,
+ <span wicket:id="closedTickets" />,
+ <span wicket:id="totalTickets" />
+ </div>
+</div>
+</wicket:fragment>
+
+<wicket:fragment wicket:id="milestoneDropdownFragment">
+<div class="btn-group pull-right">
+ <a class="btn dropdown-toggle" data-toggle="dropdown" href="#"><i class="fa fa-gear"></i> <span class="caret"></span></a>
+ <ul class="dropdown-menu">
+ <li wicket:id="milestone"><span wicket:id="milestoneLink">[milestone]</span></li>
+ <li class="divider"></li>
+ <li><a wicket:id="resetMilestone"><i class="fa fa-bolt"></i> <wicket:message key="gb.reset"></wicket:message></a></li>
+ </ul>
+</div>
+</wicket:fragment>
+
+<wicket:fragment wicket:id="dynamicQueriesFragment">
+ <hr/>
+ <ul class="nav nav-list">
+ <li class="nav-header"><wicket:message key="gb.topicsAndLabels"></wicket:message></li>
+ <li class="dynamicQuery" wicket:id="dynamicQuery"><span><span wicket:id="swatch"></span> <span wicket:id="link"></span></span><span class="pull-right"><i style="font-size: 18px;" wicket:id="checked"></i></span></li>
+ </ul>
+</wicket:fragment>
+
+<wicket:fragment wicket:id="updatedFragment">
+ <div class="ticket-list-details">
+ <wicket:message key="gb.updatedBy"></wicket:message>
+ <span style="padding: 0px 2px" wicket:id="updatedBy">[updatedBy]</span> <span class="date" wicket:id="updateDate">[update date]</span>
+ </div>
+</wicket:fragment>
+
+</wicket:extend>
+</body>
+</html>
\ No newline at end of file diff --git a/src/main/java/com/gitblit/wicket/pages/TicketsPage.java b/src/main/java/com/gitblit/wicket/pages/TicketsPage.java new file mode 100644 index 00000000..525658c5 --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/TicketsPage.java @@ -0,0 +1,878 @@ +/*
+ * Copyright 2013 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.wicket.pages;
+
+import java.io.Serializable;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+import org.apache.wicket.Component;
+import org.apache.wicket.PageParameters;
+import org.apache.wicket.behavior.SimpleAttributeModifier;
+import org.apache.wicket.markup.html.basic.Label;
+import org.apache.wicket.markup.html.form.TextField;
+import org.apache.wicket.markup.html.link.BookmarkablePageLink;
+import org.apache.wicket.markup.html.panel.Fragment;
+import org.apache.wicket.markup.repeater.Item;
+import org.apache.wicket.markup.repeater.data.DataView;
+import org.apache.wicket.markup.repeater.data.ListDataProvider;
+import org.apache.wicket.model.IModel;
+import org.apache.wicket.model.Model;
+import org.apache.wicket.request.target.basic.RedirectRequestTarget;
+
+import com.gitblit.Constants;
+import com.gitblit.Constants.AccessPermission;
+import com.gitblit.Keys;
+import com.gitblit.models.RegistrantAccessPermission;
+import com.gitblit.models.TicketModel;
+import com.gitblit.models.TicketModel.Status;
+import com.gitblit.models.UserModel;
+import com.gitblit.tickets.QueryBuilder;
+import com.gitblit.tickets.QueryResult;
+import com.gitblit.tickets.TicketIndexer.Lucene;
+import com.gitblit.tickets.TicketLabel;
+import com.gitblit.tickets.TicketMilestone;
+import com.gitblit.tickets.TicketResponsible;
+import com.gitblit.utils.ArrayUtils;
+import com.gitblit.utils.StringUtils;
+import com.gitblit.wicket.GitBlitWebSession;
+import com.gitblit.wicket.SessionlessForm;
+import com.gitblit.wicket.WicketUtils;
+import com.gitblit.wicket.panels.GravatarImage;
+import com.gitblit.wicket.panels.LinkPanel;
+
+public class TicketsPage extends TicketBasePage {
+
+ final TicketResponsible any;
+
+ public static final String [] openStatii = new String [] { Status.New.name().toLowerCase(), Status.Open.name().toLowerCase() };
+
+ public static final String [] closedStatii = new String [] { "!" + Status.New.name().toLowerCase(), "!" + Status.Open.name().toLowerCase() };
+
+ public TicketsPage(PageParameters params) {
+ super(params);
+
+ if (!app().tickets().isReady()) {
+ // tickets prohibited
+ setResponsePage(SummaryPage.class, WicketUtils.newRepositoryParameter(repositoryName));
+ } else if (!app().tickets().hasTickets(getRepositoryModel())) {
+ // no tickets for this repository
+ setResponsePage(NoTicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
+ } else {
+ String id = WicketUtils.getObject(params);
+ if (id != null) {
+ // view the ticket with the TicketPage
+ setResponsePage(TicketPage.class, params);
+ }
+ }
+
+ // set stateless page preference
+ setStatelessHint(true);
+
+ any = new TicketResponsible("any", "[* TO *]", null);
+
+ UserModel user = GitBlitWebSession.get().getUser();
+ boolean isAuthenticated = user != null && user.isAuthenticated;
+
+ final String [] statiiParam = params.getStringArray(Lucene.status.name());
+ final String assignedToParam = params.getString(Lucene.responsible.name(), null);
+ final String milestoneParam = params.getString(Lucene.milestone.name(), null);
+ final String queryParam = params.getString("q", null);
+ final String searchParam = params.getString("s", null);
+ final String sortBy = Lucene.fromString(params.getString("sort", Lucene.created.name())).name();
+ final boolean desc = !"asc".equals(params.getString("direction", "desc"));
+
+
+ // add search form
+ TicketSearchForm searchForm = new TicketSearchForm("ticketSearchForm", repositoryName, searchParam);
+ add(searchForm);
+ searchForm.setTranslatedAttributes();
+
+ final String activeQuery;
+ if (!StringUtils.isEmpty(searchParam)) {
+ activeQuery = searchParam;
+ } else if (StringUtils.isEmpty(queryParam)) {
+ activeQuery = "";
+ } else {
+ activeQuery = queryParam;
+ }
+
+ // build Lucene query from defaults and request parameters
+ QueryBuilder qb = new QueryBuilder(queryParam);
+ if (!qb.containsField(Lucene.rid.name())) {
+ // specify the repository
+ qb.and(Lucene.rid.matches(getRepositoryModel().getRID()));
+ }
+ if (!qb.containsField(Lucene.responsible.name())) {
+ // specify the responsible
+ qb.and(Lucene.responsible.matches(assignedToParam));
+ }
+ if (!qb.containsField(Lucene.milestone.name())) {
+ // specify the milestone
+ qb.and(Lucene.milestone.matches(milestoneParam));
+ }
+ if (!qb.containsField(Lucene.status.name()) && !ArrayUtils.isEmpty(statiiParam)) {
+ // specify the states
+ boolean not = false;
+ QueryBuilder q = new QueryBuilder();
+ for (String state : statiiParam) {
+ if (state.charAt(0) == '!') {
+ not = true;
+ q.and(Lucene.status.doesNotMatch(state.substring(1)));
+ } else {
+ q.or(Lucene.status.matches(state));
+ }
+ }
+ if (not) {
+ qb.and(q.toString());
+ } else {
+ qb.and(q.toSubquery().toString());
+ }
+ }
+ final String luceneQuery = qb.build();
+
+ // open milestones
+ List<TicketMilestone> milestones = app().tickets().getMilestones(getRepositoryModel(), Status.Open);
+ TicketMilestone currentMilestone = null;
+ if (!StringUtils.isEmpty(milestoneParam)) {
+ for (TicketMilestone tm : milestones) {
+ if (tm.name.equals(milestoneParam)) {
+ // get the milestone (queries the index)
+ currentMilestone = app().tickets().getMilestone(getRepositoryModel(), milestoneParam);
+ break;
+ }
+ }
+
+ if (currentMilestone == null) {
+ // milestone not found, create a temporary one
+ currentMilestone = new TicketMilestone(milestoneParam);
+ }
+ }
+
+ Fragment milestonePanel;
+ if (currentMilestone == null) {
+ milestonePanel = new Fragment("milestonePanel", "noMilestoneFragment", this);
+ add(milestonePanel);
+ } else {
+ milestonePanel = new Fragment("milestonePanel", "milestoneProgressFragment", this);
+ milestonePanel.add(new Label("currentMilestone", currentMilestone.name));
+ if (currentMilestone.due == null) {
+ milestonePanel.add(new Label("currentDueDate", getString("gb.notSpecified")));
+ } else {
+ milestonePanel.add(WicketUtils.createDateLabel("currentDueDate", currentMilestone.due, GitBlitWebSession
+ .get().getTimezone(), getTimeUtils(), false));
+ }
+ Label label = new Label("progress");
+ WicketUtils.setCssStyle(label, "width:" + currentMilestone.getProgress() + "%;");
+ milestonePanel.add(label);
+
+ milestonePanel.add(new LinkPanel("openTickets", null,
+ currentMilestone.getOpenTickets() + " open",
+ TicketsPage.class,
+ queryParameters(null, currentMilestone.name, openStatii, null, sortBy, desc, 1)));
+
+ milestonePanel.add(new LinkPanel("closedTickets", null,
+ currentMilestone.getClosedTickets() + " closed",
+ TicketsPage.class,
+ queryParameters(null, currentMilestone.name, closedStatii, null, sortBy, desc, 1)));
+
+ milestonePanel.add(new Label("totalTickets", currentMilestone.getTotalTickets() + " total"));
+ add(milestonePanel);
+ }
+
+ Fragment milestoneDropdown = new Fragment("milestoneDropdown", "milestoneDropdownFragment", this);
+ PageParameters resetMilestone = queryParameters(queryParam, null, statiiParam, assignedToParam, sortBy, desc, 1);
+ milestoneDropdown.add(new BookmarkablePageLink<Void>("resetMilestone", TicketsPage.class, resetMilestone));
+
+ ListDataProvider<TicketMilestone> milestonesDp = new ListDataProvider<TicketMilestone>(milestones);
+ DataView<TicketMilestone> milestonesMenu = new DataView<TicketMilestone>("milestone", milestonesDp) {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void populateItem(final Item<TicketMilestone> item) {
+ final TicketMilestone tm = item.getModelObject();
+ PageParameters params = queryParameters(queryParam, tm.name, statiiParam, assignedToParam, sortBy, desc, 1);
+ item.add(new LinkPanel("milestoneLink", null, tm.name, TicketsPage.class, params).setRenderBodyOnly(true));
+ }
+ };
+ milestoneDropdown.add(milestonesMenu);
+ milestonePanel.add(milestoneDropdown);
+
+ // search or query tickets
+ int page = Math.max(1, WicketUtils.getPage(params));
+ int pageSize = app().settings().getInteger(Keys.tickets.perPage, 25);
+ List<QueryResult> results;
+ if (StringUtils.isEmpty(searchParam)) {
+ results = app().tickets().queryFor(luceneQuery, page, pageSize, sortBy, desc);
+ } else {
+ results = app().tickets().searchFor(getRepositoryModel(), searchParam, page, pageSize);
+ }
+ int totalResults = results.size() == 0 ? 0 : results.get(0).totalResults;
+
+ // standard queries
+ add(new BookmarkablePageLink<Void>("changesQuery", TicketsPage.class,
+ queryParameters(
+ Lucene.type.matches(TicketModel.Type.Proposal.name()),
+ milestoneParam,
+ statiiParam,
+ assignedToParam,
+ sortBy,
+ desc,
+ 1)));
+
+ add(new BookmarkablePageLink<Void>("bugsQuery", TicketsPage.class,
+ queryParameters(
+ Lucene.type.matches(TicketModel.Type.Bug.name()),
+ milestoneParam,
+ statiiParam,
+ assignedToParam,
+ sortBy,
+ desc,
+ 1)));
+
+ add(new BookmarkablePageLink<Void>("enhancementsQuery", TicketsPage.class,
+ queryParameters(
+ Lucene.type.matches(TicketModel.Type.Enhancement.name()),
+ milestoneParam,
+ statiiParam,
+ assignedToParam,
+ sortBy,
+ desc,
+ 1)));
+
+ add(new BookmarkablePageLink<Void>("tasksQuery", TicketsPage.class,
+ queryParameters(
+ Lucene.type.matches(TicketModel.Type.Task.name()),
+ milestoneParam,
+ statiiParam,
+ assignedToParam,
+ sortBy,
+ desc,
+ 1)));
+
+ add(new BookmarkablePageLink<Void>("questionsQuery", TicketsPage.class,
+ queryParameters(
+ Lucene.type.matches(TicketModel.Type.Question.name()),
+ milestoneParam,
+ statiiParam,
+ assignedToParam,
+ sortBy,
+ desc,
+ 1)));
+
+ add(new BookmarkablePageLink<Void>("resetQuery", TicketsPage.class,
+ queryParameters(
+ null,
+ milestoneParam,
+ openStatii,
+ null,
+ null,
+ true,
+ 1)));
+
+ if (isAuthenticated) {
+ add(new Label("userDivider"));
+ add(new BookmarkablePageLink<Void>("createdQuery", TicketsPage.class,
+ queryParameters(
+ Lucene.createdby.matches(user.username),
+ milestoneParam,
+ statiiParam,
+ assignedToParam,
+ sortBy,
+ desc,
+ 1)));
+
+ add(new BookmarkablePageLink<Void>("watchedQuery", TicketsPage.class,
+ queryParameters(
+ Lucene.watchedby.matches(user.username),
+ milestoneParam,
+ statiiParam,
+ assignedToParam,
+ sortBy,
+ desc,
+ 1)));
+ add(new BookmarkablePageLink<Void>("mentionsQuery", TicketsPage.class,
+ queryParameters(
+ Lucene.mentions.matches(user.username),
+ milestoneParam,
+ statiiParam,
+ assignedToParam,
+ sortBy,
+ desc,
+ 1)));
+ } else {
+ add(new Label("userDivider").setVisible(false));
+ add(new Label("createdQuery").setVisible(false));
+ add(new Label("watchedQuery").setVisible(false));
+ add(new Label("mentionsQuery").setVisible(false));
+ }
+
+ Set<TicketQuery> dynamicQueries = new TreeSet<TicketQuery>();
+ for (TicketLabel label : app().tickets().getLabels(getRepositoryModel())) {
+ String q = QueryBuilder.q(Lucene.labels.matches(label.name)).build();
+ dynamicQueries.add(new TicketQuery(label.name, q).color(label.color));
+ }
+
+ for (QueryResult ticket : results) {
+ if (!StringUtils.isEmpty(ticket.topic)) {
+ String q = QueryBuilder.q(Lucene.topic.matches(ticket.topic)).build();
+ dynamicQueries.add(new TicketQuery(ticket.topic, q));
+ }
+
+ if (!ArrayUtils.isEmpty(ticket.labels)) {
+ for (String label : ticket.labels) {
+ String q = QueryBuilder.q(Lucene.labels.matches(label)).build();
+ dynamicQueries.add(new TicketQuery(label, q));
+ }
+ }
+ }
+
+ if (dynamicQueries.size() == 0) {
+ add(new Label("dynamicQueries").setVisible(false));
+ } else {
+ Fragment fragment = new Fragment("dynamicQueries", "dynamicQueriesFragment", this);
+ ListDataProvider<TicketQuery> dynamicQueriesDp = new ListDataProvider<TicketQuery>(new ArrayList<TicketQuery>(dynamicQueries));
+ DataView<TicketQuery> dynamicQueriesList = new DataView<TicketQuery>("dynamicQuery", dynamicQueriesDp) {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void populateItem(final Item<TicketQuery> item) {
+ final TicketQuery tq = item.getModelObject();
+ Component swatch = new Label("swatch", " ").setEscapeModelStrings(false);
+ if (StringUtils.isEmpty(tq.color)) {
+ // calculate a color
+ tq.color = StringUtils.getColor(tq.name);
+ }
+ String background = MessageFormat.format("background-color:{0};", tq.color);
+ swatch.add(new SimpleAttributeModifier("style", background));
+ item.add(swatch);
+ if (activeQuery.contains(tq.query)) {
+ // selected
+ String q = QueryBuilder.q(activeQuery).remove(tq.query).build();
+ PageParameters params = queryParameters(q, milestoneParam, statiiParam, assignedToParam, sortBy, desc, 1);
+ item.add(new LinkPanel("link", "active", tq.name, TicketsPage.class, params).setRenderBodyOnly(true));
+ Label checked = new Label("checked");
+ WicketUtils.setCssClass(checked, "iconic-o-x");
+ item.add(checked);
+ item.add(new SimpleAttributeModifier("style", background));
+ } else {
+ // unselected
+ String q = QueryBuilder.q(queryParam).toSubquery().and(tq.query).build();
+ PageParameters params = queryParameters(q, milestoneParam, statiiParam, assignedToParam, sortBy, desc, 1);
+ item.add(new LinkPanel("link", null, tq.name, TicketsPage.class, params).setRenderBodyOnly(true));
+ item.add(new Label("checked").setVisible(false));
+ }
+ }
+ };
+ fragment.add(dynamicQueriesList);
+ add(fragment);
+ }
+
+ // states
+ if (ArrayUtils.isEmpty(statiiParam)) {
+ add(new Label("selectedStatii", getString("gb.all")));
+ } else {
+ add(new Label("selectedStatii", StringUtils.flattenStrings(Arrays.asList(statiiParam), ",")));
+ }
+ add(new BookmarkablePageLink<Void>("openTickets", TicketsPage.class, queryParameters(queryParam, milestoneParam, openStatii, assignedToParam, sortBy, desc, 1)));
+ add(new BookmarkablePageLink<Void>("closedTickets", TicketsPage.class, queryParameters(queryParam, milestoneParam, closedStatii, assignedToParam, sortBy, desc, 1)));
+ add(new BookmarkablePageLink<Void>("allTickets", TicketsPage.class, queryParameters(queryParam, milestoneParam, null, assignedToParam, sortBy, desc, 1)));
+
+ // by status
+ List<Status> statii = Arrays.asList(Status.values());
+ ListDataProvider<Status> resolutionsDp = new ListDataProvider<Status>(statii);
+ DataView<Status> statiiLinks = new DataView<Status>("statii", resolutionsDp) {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void populateItem(final Item<Status> item) {
+ final Status status = item.getModelObject();
+ PageParameters p = queryParameters(queryParam, milestoneParam, new String [] { status.name().toLowerCase() }, assignedToParam, sortBy, desc, 1);
+ String css = getStatusClass(status);
+ item.add(new LinkPanel("statusLink", css, status.toString(), TicketsPage.class, p).setRenderBodyOnly(true));
+ }
+ };
+ add(statiiLinks);
+
+ // responsible filter
+ List<TicketResponsible> responsibles = new ArrayList<TicketResponsible>();
+ for (RegistrantAccessPermission perm : app().repositories().getUserAccessPermissions(getRepositoryModel())) {
+ if (perm.permission.atLeast(AccessPermission.PUSH)) {
+ UserModel u = app().users().getUserModel(perm.registrant);
+ responsibles.add(new TicketResponsible(u));
+ }
+ }
+ Collections.sort(responsibles);
+ responsibles.add(0, any);
+
+ TicketResponsible currentResponsible = null;
+ for (TicketResponsible u : responsibles) {
+ if (u.username.equals(assignedToParam)) {
+ currentResponsible = u;
+ break;
+ }
+ }
+
+ add(new Label("currentResponsible", currentResponsible == null ? "" : currentResponsible.displayname));
+ ListDataProvider<TicketResponsible> responsibleDp = new ListDataProvider<TicketResponsible>(responsibles);
+ DataView<TicketResponsible> responsibleMenu = new DataView<TicketResponsible>("responsible", responsibleDp) {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void populateItem(final Item<TicketResponsible> item) {
+ final TicketResponsible u = item.getModelObject();
+ PageParameters params = queryParameters(queryParam, milestoneParam, statiiParam, u.username, sortBy, desc, 1);
+ item.add(new LinkPanel("responsibleLink", null, u.displayname, TicketsPage.class, params).setRenderBodyOnly(true));
+ }
+ };
+ add(responsibleMenu);
+ PageParameters resetResponsibleParams = queryParameters(queryParam, milestoneParam, statiiParam, null, sortBy, desc, 1);
+ add(new BookmarkablePageLink<Void>("resetResponsible", TicketsPage.class, resetResponsibleParams));
+
+ List<TicketSort> sortChoices = new ArrayList<TicketSort>();
+ sortChoices.add(new TicketSort(getString("gb.sortNewest"), Lucene.created.name(), true));
+ sortChoices.add(new TicketSort(getString("gb.sortOldest"), Lucene.created.name(), false));
+ sortChoices.add(new TicketSort(getString("gb.sortMostRecentlyUpdated"), Lucene.updated.name(), true));
+ sortChoices.add(new TicketSort(getString("gb.sortLeastRecentlyUpdated"), Lucene.updated.name(), false));
+ sortChoices.add(new TicketSort(getString("gb.sortMostComments"), Lucene.comments.name(), true));
+ sortChoices.add(new TicketSort(getString("gb.sortLeastComments"), Lucene.comments.name(), false));
+ sortChoices.add(new TicketSort(getString("gb.sortMostPatchsetRevisions"), Lucene.patchsets.name(), true));
+ sortChoices.add(new TicketSort(getString("gb.sortLeastPatchsetRevisions"), Lucene.patchsets.name(), false));
+ sortChoices.add(new TicketSort(getString("gb.sortMostVotes"), Lucene.votes.name(), true));
+ sortChoices.add(new TicketSort(getString("gb.sortLeastVotes"), Lucene.votes.name(), false));
+
+ TicketSort currentSort = sortChoices.get(0);
+ for (TicketSort ts : sortChoices) {
+ if (ts.sortBy.equals(sortBy) && desc == ts.desc) {
+ currentSort = ts;
+ break;
+ }
+ }
+ add(new Label("currentSort", currentSort.name));
+
+ ListDataProvider<TicketSort> sortChoicesDp = new ListDataProvider<TicketSort>(sortChoices);
+ DataView<TicketSort> sortMenu = new DataView<TicketSort>("sort", sortChoicesDp) {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void populateItem(final Item<TicketSort> item) {
+ final TicketSort ts = item.getModelObject();
+ PageParameters params = queryParameters(queryParam, milestoneParam, statiiParam, assignedToParam, ts.sortBy, ts.desc, 1);
+ item.add(new LinkPanel("sortLink", null, ts.name, TicketsPage.class, params).setRenderBodyOnly(true));
+ }
+ };
+ add(sortMenu);
+
+
+ // paging links
+ buildPager(queryParam, milestoneParam, statiiParam, assignedToParam, sortBy, desc, page, pageSize, results.size(), totalResults);
+
+ ListDataProvider<QueryResult> resultsDataProvider = new ListDataProvider<QueryResult>(results);
+ DataView<QueryResult> ticketsView = new DataView<QueryResult>("ticket", resultsDataProvider) {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void populateItem(final Item<QueryResult> item) {
+ final QueryResult ticket = item.getModelObject();
+ item.add(getStateIcon("state", ticket.type, ticket.status));
+ item.add(new Label("id", "" + ticket.number));
+ UserModel creator = app().users().getUserModel(ticket.createdBy);
+ if (creator != null) {
+ item.add(new LinkPanel("createdBy", null, creator.getDisplayName(),
+ UserPage.class, WicketUtils.newUsernameParameter(ticket.createdBy)));
+ } else {
+ item.add(new Label("createdBy", ticket.createdBy));
+ }
+ item.add(WicketUtils.createDateLabel("createDate", ticket.createdAt, GitBlitWebSession
+ .get().getTimezone(), getTimeUtils(), false));
+
+ if (ticket.updatedAt == null) {
+ item.add(new Label("updated").setVisible(false));
+ } else {
+ Fragment updated = new Fragment("updated", "updatedFragment", this);
+ UserModel updater = app().users().getUserModel(ticket.updatedBy);
+ if (updater != null) {
+ updated.add(new LinkPanel("updatedBy", null, updater.getDisplayName(),
+ UserPage.class, WicketUtils.newUsernameParameter(ticket.updatedBy)));
+ } else {
+ updated.add(new Label("updatedBy", ticket.updatedBy));
+ }
+ updated.add(WicketUtils.createDateLabel("updateDate", ticket.updatedAt, GitBlitWebSession
+ .get().getTimezone(), getTimeUtils(), false));
+ item.add(updated);
+ }
+
+ item.add(new LinkPanel("title", "list subject", StringUtils.trimString(
+ ticket.title, Constants.LEN_SHORTLOG), TicketsPage.class, newTicketParameter(ticket)));
+
+ ListDataProvider<String> labelsProvider = new ListDataProvider<String>(ticket.getLabels());
+ DataView<String> labelsView = new DataView<String>("labels", labelsProvider) {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void populateItem(final Item<String> labelItem) {
+ String content = messageProcessor().processPlainCommitMessage(getRepository(), repositoryName, labelItem.getModelObject());
+ Label label = new Label("label", content);
+ label.setEscapeModelStrings(false);
+ TicketLabel tLabel = app().tickets().getLabel(getRepositoryModel(), labelItem.getModelObject());
+ String background = MessageFormat.format("background-color:{0};", tLabel.color);
+ label.add(new SimpleAttributeModifier("style", background));
+ labelItem.add(label);
+ }
+ };
+ item.add(labelsView);
+
+ if (StringUtils.isEmpty(ticket.responsible)) {
+ item.add(new Label("responsible").setVisible(false));
+ } else {
+ UserModel responsible = app().users().getUserModel(ticket.responsible);
+ if (responsible == null) {
+ responsible = new UserModel(ticket.responsible);
+ }
+ GravatarImage avatar = new GravatarImage("responsible", responsible.getDisplayName(),
+ responsible.emailAddress, null, 16, true);
+ avatar.setTooltip(getString("gb.responsible") + ": " + responsible.getDisplayName());
+ item.add(avatar);
+ }
+
+ // votes indicator
+ Label v = new Label("votes", "" + ticket.votesCount);
+ WicketUtils.setHtmlTooltip(v, getString("gb.votes"));
+ item.add(v.setVisible(ticket.votesCount > 0));
+
+ // watching indicator
+ item.add(new Label("watching").setVisible(ticket.isWatching(GitBlitWebSession.get().getUsername())));
+
+ // status indicator
+ String css = getLozengeClass(ticket.status, true);
+ Label l = new Label("status", ticket.status.toString());
+ WicketUtils.setCssClass(l, css);
+ item.add(l);
+
+ // add the ticket indicators/icons
+ List<Indicator> indicators = new ArrayList<Indicator>();
+
+ // comments
+ if (ticket.commentsCount > 0) {
+ int count = ticket.commentsCount;
+ String pattern = "gb.nComments";
+ if (count == 1) {
+ pattern = "gb.oneComment";
+ }
+ indicators.add(new Indicator("fa fa-comment", count, pattern));
+ }
+
+ // participants
+ if (!ArrayUtils.isEmpty(ticket.participants)) {
+ int count = ticket.participants.size();
+ if (count > 1) {
+ String pattern = "gb.nParticipants";
+ indicators.add(new Indicator("fa fa-user", count, pattern));
+ }
+ }
+
+ // attachments
+ if (!ArrayUtils.isEmpty(ticket.attachments)) {
+ int count = ticket.attachments.size();
+ String pattern = "gb.nAttachments";
+ if (count == 1) {
+ pattern = "gb.oneAttachment";
+ }
+ indicators.add(new Indicator("fa fa-file", count, pattern));
+ }
+
+ // patchset revisions
+ if (ticket.patchset != null) {
+ int count = ticket.patchset.commits;
+ String pattern = "gb.nCommits";
+ if (count == 1) {
+ pattern = "gb.oneCommit";
+ }
+ indicators.add(new Indicator("fa fa-code", count, pattern));
+ }
+
+ // milestone
+ if (!StringUtils.isEmpty(ticket.milestone)) {
+ indicators.add(new Indicator("fa fa-bullseye", ticket.milestone));
+ }
+
+ ListDataProvider<Indicator> indicatorsDp = new ListDataProvider<Indicator>(indicators);
+ DataView<Indicator> indicatorsView = new DataView<Indicator>("indicators", indicatorsDp) {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void populateItem(final Item<Indicator> item) {
+ Indicator indicator = item.getModelObject();
+ String tooltip = indicator.getTooltip();
+
+ Label icon = new Label("icon");
+ WicketUtils.setCssClass(icon, indicator.css);
+ item.add(icon);
+
+ if (indicator.count > 0) {
+ Label count = new Label("count", "" + indicator.count);
+ item.add(count.setVisible(!StringUtils.isEmpty(tooltip)));
+ } else {
+ item.add(new Label("count").setVisible(false));
+ }
+
+ WicketUtils.setHtmlTooltip(item, tooltip);
+ }
+ };
+ item.add(indicatorsView);
+ }
+ };
+ add(ticketsView);
+
+ DataView<TicketMilestone> milestonesList = new DataView<TicketMilestone>("milestoneList", milestonesDp) {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void populateItem(final Item<TicketMilestone> item) {
+ final TicketMilestone tm = item.getModelObject();
+ item.add(new Label("milestoneName", tm.name));
+ item.add(new Label("milestoneState", tm.status.name()));
+ item.add(new Label("milestoneDue", tm.due == null ? getString("gb.notSpecified") : tm.due.toString()));
+ }
+ };
+ add(milestonesList);
+ }
+
+ protected PageParameters queryParameters(
+ String query,
+ String milestone,
+ String[] states,
+ String assignedTo,
+ String sort,
+ boolean descending,
+ int page) {
+
+ PageParameters params = WicketUtils.newRepositoryParameter(repositoryName);
+ if (!StringUtils.isEmpty(query)) {
+ params.add("q", query);
+ }
+ if (!StringUtils.isEmpty(milestone)) {
+ params.add(Lucene.milestone.name(), milestone);
+ }
+ if (!ArrayUtils.isEmpty(states)) {
+ for (String state : states) {
+ params.add(Lucene.status.name(), state);
+ }
+ }
+ if (!StringUtils.isEmpty(assignedTo)) {
+ params.add(Lucene.responsible.name(), assignedTo);
+ }
+ if (!StringUtils.isEmpty(sort)) {
+ params.add("sort", sort);
+ }
+ if (!descending) {
+ params.add("direction", "asc");
+ }
+ if (page > 1) {
+ params.add("pg", "" + page);
+ }
+ return params;
+ }
+
+ protected PageParameters newTicketParameter(QueryResult ticket) {
+ return WicketUtils.newObjectParameter(repositoryName, "" + ticket.number);
+ }
+
+ @Override
+ protected String getPageName() {
+ return getString("gb.tickets");
+ }
+
+ protected void buildPager(
+ final String query,
+ final String milestone,
+ final String [] states,
+ final String assignedTo,
+ final String sort,
+ final boolean desc,
+ final int page,
+ int pageSize,
+ int count,
+ int total) {
+
+ boolean showNav = total > (2 * pageSize);
+ boolean allowPrev = page > 1;
+ boolean allowNext = (pageSize * (page - 1) + count) < total;
+ add(new BookmarkablePageLink<Void>("prevLink", TicketsPage.class, queryParameters(query, milestone, states, assignedTo, sort, desc, page - 1)).setEnabled(allowPrev).setVisible(showNav));
+ add(new BookmarkablePageLink<Void>("nextLink", TicketsPage.class, queryParameters(query, milestone, states, assignedTo, sort, desc, page + 1)).setEnabled(allowNext).setVisible(showNav));
+
+ if (total <= pageSize) {
+ add(new Label("pageLink").setVisible(false));
+ return;
+ }
+
+ // determine page numbers to display
+ int pages = count == 0 ? 0 : ((total / pageSize) + (total % pageSize == 0 ? 0 : 1));
+ // preferred number of pagelinks
+ int segments = 5;
+ if (pages < segments) {
+ // not enough data for preferred number of page links
+ segments = pages;
+ }
+ int minpage = Math.min(Math.max(1, page - 2), pages - (segments - 1));
+ int maxpage = Math.min(pages, minpage + (segments - 1));
+ List<Integer> sequence = new ArrayList<Integer>();
+ for (int i = minpage; i <= maxpage; i++) {
+ sequence.add(i);
+ }
+
+ ListDataProvider<Integer> pagesDp = new ListDataProvider<Integer>(sequence);
+ DataView<Integer> pagesView = new DataView<Integer>("pageLink", pagesDp) {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void populateItem(final Item<Integer> item) {
+ final Integer i = item.getModelObject();
+ LinkPanel link = new LinkPanel("page", null, "" + i, TicketsPage.class, queryParameters(query, milestone, states, assignedTo, sort, desc, i));
+ link.setRenderBodyOnly(true);
+ if (i == page) {
+ WicketUtils.setCssClass(item, "active");
+ }
+ item.add(link);
+ }
+ };
+ add(pagesView);
+ }
+
+ private class Indicator implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ final String css;
+ final int count;
+ final String tooltip;
+
+ Indicator(String css, String tooltip) {
+ this.css = css;
+ this.tooltip = tooltip;
+ this.count = 0;
+ }
+
+ Indicator(String css, int count, String pattern) {
+ this.css = css;
+ this.count = count;
+ this.tooltip = StringUtils.isEmpty(pattern) ? "" : MessageFormat.format(getString(pattern), count);
+ }
+
+ String getTooltip() {
+ return tooltip;
+ }
+ }
+
+ private class TicketQuery implements Serializable, Comparable<TicketQuery> {
+
+ private static final long serialVersionUID = 1L;
+
+ final String name;
+ final String query;
+ String color;
+
+ TicketQuery(String name, String query) {
+ this.name = name;
+ this.query = query;
+ }
+
+ TicketQuery color(String value) {
+ this.color = value;
+ return this;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o instanceof TicketQuery) {
+ return ((TicketQuery) o).query.equals(query);
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ return query.hashCode();
+ }
+
+ @Override
+ public int compareTo(TicketQuery o) {
+ return query.compareTo(o.query);
+ }
+ }
+
+ private class TicketSort implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ final String name;
+ final String sortBy;
+ final boolean desc;
+
+ TicketSort(String name, String sortBy, boolean desc) {
+ this.name = name;
+ this.sortBy = sortBy;
+ this.desc = desc;
+ }
+ }
+
+ private class TicketSearchForm extends SessionlessForm<Void> implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ private final String repositoryName;
+
+ private final IModel<String> searchBoxModel;;
+
+ public TicketSearchForm(String id, String repositoryName, String text) {
+ super(id, TicketsPage.this.getClass(), TicketsPage.this.getPageParameters());
+
+ this.repositoryName = repositoryName;
+ this.searchBoxModel = new Model<String>(text == null ? "" : text);
+
+ TextField<String> searchBox = new TextField<String>("ticketSearchBox", searchBoxModel);
+ add(searchBox);
+ }
+
+ void setTranslatedAttributes() {
+ WicketUtils.setHtmlTooltip(get("ticketSearchBox"),
+ MessageFormat.format(getString("gb.searchTicketsTooltip"), repositoryName));
+ WicketUtils.setInputPlaceholder(get("ticketSearchBox"), getString("gb.searchTickets"));
+ }
+
+ @Override
+ public void onSubmit() {
+ String searchString = searchBoxModel.getObject();
+ if (StringUtils.isEmpty(searchString)) {
+ // redirect to self to avoid wicket page update bug
+ String absoluteUrl = getCanonicalUrl();
+ getRequestCycle().setRequestTarget(new RedirectRequestTarget(absoluteUrl));
+ return;
+ }
+
+ // use an absolute url to workaround Wicket-Tomcat problems with
+ // mounted url parameters (issue-111)
+ PageParameters params = WicketUtils.newRepositoryParameter(repositoryName);
+ params.add("s", searchString);
+ String absoluteUrl = getCanonicalUrl(TicketsPage.class, params);
+ getRequestCycle().setRequestTarget(new RedirectRequestTarget(absoluteUrl));
+ }
+ }
+}
diff --git a/src/main/java/com/gitblit/wicket/pages/propose_git.md b/src/main/java/com/gitblit/wicket/pages/propose_git.md new file mode 100644 index 00000000..1b4f429c --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/propose_git.md @@ -0,0 +1,6 @@ + git clone ${url} + cd ${repo} + git checkout -b ${reviewBranch} ${integrationBranch} + ... + git push origin HEAD:refs/for/${ticketId} + git branch --set-upstream-to=origin/${reviewBranch} diff --git a/src/main/java/com/gitblit/wicket/pages/propose_pt.md b/src/main/java/com/gitblit/wicket/pages/propose_pt.md new file mode 100644 index 00000000..949d2361 --- /dev/null +++ b/src/main/java/com/gitblit/wicket/pages/propose_pt.md @@ -0,0 +1,5 @@ + git clone ${url} + cd ${repo} + pt start ${ticketId} + ... + pt propose diff --git a/src/main/java/com/gitblit/wicket/panels/CommentPanel.html b/src/main/java/com/gitblit/wicket/panels/CommentPanel.html new file mode 100644 index 00000000..1fdfb168 --- /dev/null +++ b/src/main/java/com/gitblit/wicket/panels/CommentPanel.html @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<html xmlns="http://www.w3.org/1999/xhtml" xmlns:wicket="http://wicket.apache.org/dtds.data/wicket-xhtml1.4-strict.dtd"> +<wicket:panel> + <div style="border: 1px solid #ccc;"> + + <ul class="nav nav-pills" style="margin: 2px 5px !important"> + <li class="active"><a href="#write" data-toggle="tab"><wicket:message key="gb.write">[write]</wicket:message></a></li> + <li><a href="#preview" data-toggle="tab"><wicket:message key="gb.preview">[preview]</wicket:message></a></li> + </ul> + <div class="tab-content"> + <div class="tab-pane active" id="write"> + <textarea class="span7" style="height:7em;border-color:#ccc;border-right:0px;border-left:0px;border-radius:0px;box-shadow: none;" wicket:id="markdownEditor"></textarea> + </div> + <div class="tab-pane" id="preview"> + <div class="preview" style="height:7em;border:1px solid #ccc;border-right:0px;border-left:0px;margin-bottom:9px;padding:4px;background-color:#ffffff;"> + <div class="markdown" wicket:id="markdownPreview"></div> + </div> + </div> + </div> + + <div style="text-align:right;padding-right:5px;"> + <form style="margin-bottom:9px;" wicket:id="editorForm" action=""> + <input class="btn btn-appmenu" type="submit" wicket:id="submit" value="comment"></input> + </form> + </div> + + </div> +</wicket:panel> +</html>
\ No newline at end of file diff --git a/src/main/java/com/gitblit/wicket/panels/CommentPanel.java b/src/main/java/com/gitblit/wicket/panels/CommentPanel.java new file mode 100644 index 00000000..1d49ff0f --- /dev/null +++ b/src/main/java/com/gitblit/wicket/panels/CommentPanel.java @@ -0,0 +1,110 @@ +/* + * Copyright 2013 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.wicket.panels; + +import org.apache.wicket.ajax.AjaxRequestTarget; +import org.apache.wicket.ajax.markup.html.form.AjaxButton; +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.html.form.Form; +import org.apache.wicket.model.IModel; +import org.apache.wicket.model.Model; + +import com.gitblit.models.RepositoryModel; +import com.gitblit.models.TicketModel; +import com.gitblit.models.TicketModel.Change; +import com.gitblit.models.UserModel; +import com.gitblit.wicket.WicketUtils; +import com.gitblit.wicket.pages.BasePage; + +public class CommentPanel extends BasePanel { + private static final long serialVersionUID = 1L; + + final UserModel user; + + final TicketModel ticket; + + final Change change; + + final Class<? extends BasePage> pageClass; + + private MarkdownTextArea markdownEditor; + + private Label markdownPreview; + + private String repositoryName; + + public CommentPanel(String id, final UserModel user, final TicketModel ticket, + final Change change, final Class<? extends BasePage> pageClass) { + super(id); + this.user = user; + this.ticket = ticket; + this.change = change; + this.pageClass = pageClass; + } + + @Override + protected void onInitialize() { + super.onInitialize(); + + Form<String> form = new Form<String>("editorForm"); + add(form); + + form.add(new AjaxButton("submit", new Model<String>(getString("gb.comment")), form) { + private static final long serialVersionUID = 1L; + + @Override + public void onSubmit(AjaxRequestTarget target, Form<?> form) { + String txt = markdownEditor.getText(); + if (change == null) { + // new comment + Change newComment = new Change(user.username); + newComment.comment(txt); + if (!ticket.isWatching(user.username)) { + newComment.watch(user.username); + } + RepositoryModel repository = app().repositories().getRepositoryModel(ticket.repository); + TicketModel updatedTicket = app().tickets().updateTicket(repository, ticket.number, newComment); + if (updatedTicket != null) { + app().tickets().createNotifier().sendMailing(updatedTicket); + setResponsePage(pageClass, WicketUtils.newObjectParameter(updatedTicket.repository, "" + ticket.number)); + } else { + error("Failed to add comment!"); + } + } else { + // TODO update comment + } + } + }.setVisible(ticket != null && ticket.number > 0)); + + final IModel<String> markdownPreviewModel = new Model<String>(); + markdownPreview = new Label("markdownPreview", markdownPreviewModel); + markdownPreview.setEscapeModelStrings(false); + markdownPreview.setOutputMarkupId(true); + add(markdownPreview); + + markdownEditor = new MarkdownTextArea("markdownEditor", markdownPreviewModel, markdownPreview); + markdownEditor.setRepository(repositoryName); + WicketUtils.setInputPlaceholder(markdownEditor, getString("gb.leaveComment")); + add(markdownEditor); + } + + public void setRepository(String repositoryName) { + this.repositoryName = repositoryName; + if (markdownEditor != null) { + markdownEditor.setRepository(repositoryName); + } + } +}
\ No newline at end of file diff --git a/src/main/java/com/gitblit/wicket/panels/DigestsPanel.java b/src/main/java/com/gitblit/wicket/panels/DigestsPanel.java index de09aa95..decfda50 100644 --- a/src/main/java/com/gitblit/wicket/panels/DigestsPanel.java +++ b/src/main/java/com/gitblit/wicket/panels/DigestsPanel.java @@ -1,263 +1,276 @@ -/*
- * Copyright 2013 gitblit.com.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.gitblit.wicket.panels;
-
-import java.text.DateFormat;
-import java.text.MessageFormat;
-import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.List;
-import java.util.TimeZone;
-
-import org.apache.wicket.markup.html.basic.Label;
-import org.apache.wicket.markup.repeater.Item;
-import org.apache.wicket.markup.repeater.data.DataView;
-import org.apache.wicket.markup.repeater.data.ListDataProvider;
-import org.eclipse.jgit.lib.PersonIdent;
-
-import com.gitblit.Constants;
-import com.gitblit.Keys;
-import com.gitblit.models.DailyLogEntry;
-import com.gitblit.models.RepositoryCommit;
-import com.gitblit.utils.StringUtils;
-import com.gitblit.utils.TimeUtils;
-import com.gitblit.wicket.WicketUtils;
-import com.gitblit.wicket.pages.CommitPage;
-import com.gitblit.wicket.pages.ComparePage;
-import com.gitblit.wicket.pages.SummaryPage;
-import com.gitblit.wicket.pages.TagPage;
-import com.gitblit.wicket.pages.TreePage;
-
-public class DigestsPanel extends BasePanel {
-
- private static final long serialVersionUID = 1L;
-
- private final boolean hasChanges;
-
- private boolean hasMore;
-
- public DigestsPanel(String wicketId, List<DailyLogEntry> digests) {
- super(wicketId);
- hasChanges = digests.size() > 0;
-
- ListDataProvider<DailyLogEntry> dp = new ListDataProvider<DailyLogEntry>(digests);
- DataView<DailyLogEntry> pushView = new DataView<DailyLogEntry>("change", dp) {
- private static final long serialVersionUID = 1L;
-
- @Override
- public void populateItem(final Item<DailyLogEntry> logItem) {
- final DailyLogEntry change = logItem.getModelObject();
-
- String dateFormat = app().settings().getString(Keys.web.datestampLongFormat, "EEEE, MMMM d, yyyy");
- TimeZone timezone = getTimeZone();
- DateFormat df = new SimpleDateFormat(dateFormat);
- df.setTimeZone(timezone);
-
- String fullRefName = change.getChangedRefs().get(0);
- String shortRefName = fullRefName;
- boolean isTag = false;
- if (shortRefName.startsWith(Constants.R_HEADS)) {
- shortRefName = shortRefName.substring(Constants.R_HEADS.length());
- } else if (shortRefName.startsWith(Constants.R_TAGS)) {
- shortRefName = shortRefName.substring(Constants.R_TAGS.length());
- isTag = true;
- }
-
- String fuzzydate;
- TimeUtils tu = getTimeUtils();
- Date pushDate = change.date;
- if (TimeUtils.isToday(pushDate, timezone)) {
- fuzzydate = tu.today();
- } else if (TimeUtils.isYesterday(pushDate, timezone)) {
- fuzzydate = tu.yesterday();
- } else {
- fuzzydate = getTimeUtils().timeAgo(pushDate);
- }
- logItem.add(new Label("whenChanged", fuzzydate + ", " + df.format(pushDate)));
-
- Label changeIcon = new Label("changeIcon");
- // use the repository hash color to differentiate the icon.
- String color = StringUtils.getColor(StringUtils.stripDotGit(change.repository));
- WicketUtils.setCssStyle(changeIcon, "color: " + color);
-
- if (isTag) {
- WicketUtils.setCssClass(changeIcon, "iconic-tag");
- } else {
- WicketUtils.setCssClass(changeIcon, "iconic-loop");
- }
- logItem.add(changeIcon);
-
- if (isTag) {
- // tags are special
- PersonIdent ident = change.getCommits().get(0).getAuthorIdent();
- if (!StringUtils.isEmpty(ident.getName())) {
- logItem.add(new Label("whoChanged", ident.getName()));
- } else {
- logItem.add(new Label("whoChanged", ident.getEmailAddress()));
- }
- } else {
- logItem.add(new Label("whoChanged").setVisible(false));
- }
-
- String preposition = "gb.of";
- boolean isDelete = false;
- String what;
- String by = null;
- switch(change.getChangeType(fullRefName)) {
- case CREATE:
- if (isTag) {
- // new tag
- what = getString("gb.createdNewTag");
- preposition = "gb.in";
- } else {
- // new branch
- what = getString("gb.createdNewBranch");
- preposition = "gb.in";
- }
- break;
- case DELETE:
- isDelete = true;
- if (isTag) {
- what = getString("gb.deletedTag");
- } else {
- what = getString("gb.deletedBranch");
- }
- preposition = "gb.from";
- break;
- default:
- what = MessageFormat.format(change.getCommitCount() > 1 ? getString("gb.commitsTo") : getString("gb.oneCommitTo"), change.getCommitCount());
-
- if (change.getAuthorCount() == 1) {
- by = MessageFormat.format(getString("gb.byOneAuthor"), change.getAuthorIdent().getName());
- } else {
- by = MessageFormat.format(getString("gb.byNAuthors"), change.getAuthorCount());
- }
- break;
- }
- logItem.add(new Label("whatChanged", what));
- logItem.add(new Label("byAuthors", by).setVisible(!StringUtils.isEmpty(by)));
-
- if (isDelete) {
- // can't link to deleted ref
- logItem.add(new Label("refChanged", shortRefName));
- } else if (isTag) {
- // link to tag
- logItem.add(new LinkPanel("refChanged", null, shortRefName,
- TagPage.class, WicketUtils.newObjectParameter(change.repository, fullRefName)));
- } else {
- // link to tree
- logItem.add(new LinkPanel("refChanged", null, shortRefName,
- TreePage.class, WicketUtils.newObjectParameter(change.repository, fullRefName)));
- }
-
- // to/from/etc
- logItem.add(new Label("repoPreposition", getString(preposition)));
- String repoName = StringUtils.stripDotGit(change.repository);
- logItem.add(new LinkPanel("repoChanged", null, repoName,
- SummaryPage.class, WicketUtils.newRepositoryParameter(change.repository)));
-
- int maxCommitCount = 5;
- List<RepositoryCommit> commits = change.getCommits();
- if (commits.size() > maxCommitCount) {
- commits = new ArrayList<RepositoryCommit>(commits.subList(0, maxCommitCount));
- }
-
- // compare link
- String compareLinkText = null;
- if ((change.getCommitCount() <= maxCommitCount) && (change.getCommitCount() > 1)) {
- compareLinkText = MessageFormat.format(getString("gb.viewComparison"), commits.size());
- } else if (change.getCommitCount() > maxCommitCount) {
- int diff = change.getCommitCount() - maxCommitCount;
- compareLinkText = MessageFormat.format(diff > 1 ? getString("gb.nMoreCommits") : getString("gb.oneMoreCommit"), diff);
- }
- if (StringUtils.isEmpty(compareLinkText)) {
- logItem.add(new Label("compareLink").setVisible(false));
- } else {
- String endRangeId = change.getNewId(fullRefName);
- String startRangeId = change.getOldId(fullRefName);
- logItem.add(new LinkPanel("compareLink", null, compareLinkText, ComparePage.class, WicketUtils.newRangeParameter(change.repository, startRangeId, endRangeId)));
- }
-
- final boolean showSwatch = app().settings().getBoolean(Keys.web.repositoryListSwatches, true);
-
- ListDataProvider<RepositoryCommit> cdp = new ListDataProvider<RepositoryCommit>(commits);
- DataView<RepositoryCommit> commitsView = new DataView<RepositoryCommit>("commit", cdp) {
- private static final long serialVersionUID = 1L;
-
- @Override
- public void populateItem(final Item<RepositoryCommit> commitItem) {
- final RepositoryCommit commit = commitItem.getModelObject();
-
- // author gravatar
- commitItem.add(new GravatarImage("commitAuthor", commit.getAuthorIdent(), null, 16, false));
-
- // merge icon
- if (commit.getParentCount() > 1) {
- commitItem.add(WicketUtils.newImage("commitIcon", "commit_merge_16x16.png"));
- } else {
- commitItem.add(WicketUtils.newBlankImage("commitIcon"));
- }
-
- // short message
- String shortMessage = commit.getShortMessage();
- String trimmedMessage = shortMessage;
- if (commit.getRefs() != null && commit.getRefs().size() > 0) {
- trimmedMessage = StringUtils.trimString(shortMessage, Constants.LEN_SHORTLOG_REFS);
- } else {
- trimmedMessage = StringUtils.trimString(shortMessage, Constants.LEN_SHORTLOG);
- }
- LinkPanel shortlog = new LinkPanel("commitShortMessage", "list",
- trimmedMessage, CommitPage.class, WicketUtils.newObjectParameter(
- change.repository, commit.getName()));
- if (!shortMessage.equals(trimmedMessage)) {
- WicketUtils.setHtmlTooltip(shortlog, shortMessage);
- }
- commitItem.add(shortlog);
-
- // commit hash link
- int hashLen = app().settings().getInteger(Keys.web.shortCommitIdLength, 6);
- LinkPanel commitHash = new LinkPanel("hashLink", null, commit.getName().substring(0, hashLen),
- CommitPage.class, WicketUtils.newObjectParameter(
- change.repository, commit.getName()));
- WicketUtils.setCssClass(commitHash, "shortsha1");
- WicketUtils.setHtmlTooltip(commitHash, commit.getName());
- commitItem.add(commitHash);
-
- if (showSwatch) {
- // set repository color
- String color = StringUtils.getColor(StringUtils.stripDotGit(change.repository));
- WicketUtils.setCssStyle(commitItem, MessageFormat.format("border-left: 2px solid {0};", color));
- }
- }
- };
-
- logItem.add(commitsView);
- }
- };
-
- add(pushView);
- }
-
- public boolean hasMore() {
- return hasMore;
- }
-
- public boolean hideIfEmpty() {
- setVisible(hasChanges);
- return hasChanges;
- }
-}
+/* + * Copyright 2013 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.wicket.panels; + +import java.text.DateFormat; +import java.text.MessageFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.TimeZone; + +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.repeater.Item; +import org.apache.wicket.markup.repeater.data.DataView; +import org.apache.wicket.markup.repeater.data.ListDataProvider; +import org.eclipse.jgit.lib.PersonIdent; + +import com.gitblit.Constants; +import com.gitblit.Keys; +import com.gitblit.models.DailyLogEntry; +import com.gitblit.models.RepositoryCommit; +import com.gitblit.utils.StringUtils; +import com.gitblit.utils.TimeUtils; +import com.gitblit.wicket.WicketUtils; +import com.gitblit.wicket.pages.CommitPage; +import com.gitblit.wicket.pages.ComparePage; +import com.gitblit.wicket.pages.SummaryPage; +import com.gitblit.wicket.pages.TagPage; +import com.gitblit.wicket.pages.TicketsPage; +import com.gitblit.wicket.pages.TreePage; + +public class DigestsPanel extends BasePanel { + + private static final long serialVersionUID = 1L; + + private final boolean hasChanges; + + private boolean hasMore; + + public DigestsPanel(String wicketId, List<DailyLogEntry> digests) { + super(wicketId); + hasChanges = digests.size() > 0; + + ListDataProvider<DailyLogEntry> dp = new ListDataProvider<DailyLogEntry>(digests); + DataView<DailyLogEntry> pushView = new DataView<DailyLogEntry>("change", dp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item<DailyLogEntry> logItem) { + final DailyLogEntry change = logItem.getModelObject(); + + String dateFormat = app().settings().getString(Keys.web.datestampLongFormat, "EEEE, MMMM d, yyyy"); + TimeZone timezone = getTimeZone(); + DateFormat df = new SimpleDateFormat(dateFormat); + df.setTimeZone(timezone); + + String fullRefName = change.getChangedRefs().get(0); + String shortRefName = fullRefName; + String ticketId = ""; + boolean isTag = false; + boolean isTicket = false; + if (shortRefName.startsWith(Constants.R_TICKET)) { + ticketId = shortRefName = shortRefName.substring(Constants.R_TICKET.length()); + shortRefName = MessageFormat.format(getString("gb.ticketN"), ticketId); + isTicket = true; + } else if (shortRefName.startsWith(Constants.R_HEADS)) { + shortRefName = shortRefName.substring(Constants.R_HEADS.length()); + } else if (shortRefName.startsWith(Constants.R_TAGS)) { + shortRefName = shortRefName.substring(Constants.R_TAGS.length()); + isTag = true; + } + + String fuzzydate; + TimeUtils tu = getTimeUtils(); + Date pushDate = change.date; + if (TimeUtils.isToday(pushDate, timezone)) { + fuzzydate = tu.today(); + } else if (TimeUtils.isYesterday(pushDate, timezone)) { + fuzzydate = tu.yesterday(); + } else { + fuzzydate = getTimeUtils().timeAgo(pushDate); + } + logItem.add(new Label("whenChanged", fuzzydate + ", " + df.format(pushDate))); + + Label changeIcon = new Label("changeIcon"); + // use the repository hash color to differentiate the icon. + String color = StringUtils.getColor(StringUtils.stripDotGit(change.repository)); + WicketUtils.setCssStyle(changeIcon, "color: " + color); + + if (isTag) { + WicketUtils.setCssClass(changeIcon, "iconic-tag"); + } else if (isTicket) { + WicketUtils.setCssClass(changeIcon, "fa fa-ticket"); + } else { + WicketUtils.setCssClass(changeIcon, "iconic-loop"); + } + logItem.add(changeIcon); + + if (isTag) { + // tags are special + PersonIdent ident = change.getCommits().get(0).getAuthorIdent(); + if (!StringUtils.isEmpty(ident.getName())) { + logItem.add(new Label("whoChanged", ident.getName())); + } else { + logItem.add(new Label("whoChanged", ident.getEmailAddress())); + } + } else { + logItem.add(new Label("whoChanged").setVisible(false)); + } + + String preposition = "gb.of"; + boolean isDelete = false; + String what; + String by = null; + switch(change.getChangeType(fullRefName)) { + case CREATE: + if (isTag) { + // new tag + what = getString("gb.createdNewTag"); + preposition = "gb.in"; + } else { + // new branch + what = getString("gb.createdNewBranch"); + preposition = "gb.in"; + } + break; + case DELETE: + isDelete = true; + if (isTag) { + what = getString("gb.deletedTag"); + } else { + what = getString("gb.deletedBranch"); + } + preposition = "gb.from"; + break; + default: + what = MessageFormat.format(change.getCommitCount() > 1 ? getString("gb.commitsTo") : getString("gb.oneCommitTo"), change.getCommitCount()); + + if (change.getAuthorCount() == 1) { + by = MessageFormat.format(getString("gb.byOneAuthor"), change.getAuthorIdent().getName()); + } else { + by = MessageFormat.format(getString("gb.byNAuthors"), change.getAuthorCount()); + } + break; + } + logItem.add(new Label("whatChanged", what)); + logItem.add(new Label("byAuthors", by).setVisible(!StringUtils.isEmpty(by))); + + if (isDelete) { + // can't link to deleted ref + logItem.add(new Label("refChanged", shortRefName)); + } else if (isTag) { + // link to tag + logItem.add(new LinkPanel("refChanged", null, shortRefName, + TagPage.class, WicketUtils.newObjectParameter(change.repository, fullRefName))); + } else if (isTicket) { + // link to ticket + logItem.add(new LinkPanel("refChanged", null, shortRefName, + TicketsPage.class, WicketUtils.newObjectParameter(change.repository, ticketId))); + } else { + // link to tree + logItem.add(new LinkPanel("refChanged", null, shortRefName, + TreePage.class, WicketUtils.newObjectParameter(change.repository, fullRefName))); + } + + // to/from/etc + logItem.add(new Label("repoPreposition", getString(preposition))); + String repoName = StringUtils.stripDotGit(change.repository); + logItem.add(new LinkPanel("repoChanged", null, repoName, + SummaryPage.class, WicketUtils.newRepositoryParameter(change.repository))); + + int maxCommitCount = 5; + List<RepositoryCommit> commits = change.getCommits(); + if (commits.size() > maxCommitCount) { + commits = new ArrayList<RepositoryCommit>(commits.subList(0, maxCommitCount)); + } + + // compare link + String compareLinkText = null; + if ((change.getCommitCount() <= maxCommitCount) && (change.getCommitCount() > 1)) { + compareLinkText = MessageFormat.format(getString("gb.viewComparison"), commits.size()); + } else if (change.getCommitCount() > maxCommitCount) { + int diff = change.getCommitCount() - maxCommitCount; + compareLinkText = MessageFormat.format(diff > 1 ? getString("gb.nMoreCommits") : getString("gb.oneMoreCommit"), diff); + } + if (StringUtils.isEmpty(compareLinkText)) { + logItem.add(new Label("compareLink").setVisible(false)); + } else { + String endRangeId = change.getNewId(fullRefName); + String startRangeId = change.getOldId(fullRefName); + logItem.add(new LinkPanel("compareLink", null, compareLinkText, ComparePage.class, WicketUtils.newRangeParameter(change.repository, startRangeId, endRangeId))); + } + + final boolean showSwatch = app().settings().getBoolean(Keys.web.repositoryListSwatches, true); + + ListDataProvider<RepositoryCommit> cdp = new ListDataProvider<RepositoryCommit>(commits); + DataView<RepositoryCommit> commitsView = new DataView<RepositoryCommit>("commit", cdp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item<RepositoryCommit> commitItem) { + final RepositoryCommit commit = commitItem.getModelObject(); + + // author gravatar + commitItem.add(new GravatarImage("commitAuthor", commit.getAuthorIdent(), null, 16, false)); + + // merge icon + if (commit.getParentCount() > 1) { + commitItem.add(WicketUtils.newImage("commitIcon", "commit_merge_16x16.png")); + } else { + commitItem.add(WicketUtils.newBlankImage("commitIcon")); + } + + // short message + String shortMessage = commit.getShortMessage(); + String trimmedMessage = shortMessage; + if (commit.getRefs() != null && commit.getRefs().size() > 0) { + trimmedMessage = StringUtils.trimString(shortMessage, Constants.LEN_SHORTLOG_REFS); + } else { + trimmedMessage = StringUtils.trimString(shortMessage, Constants.LEN_SHORTLOG); + } + LinkPanel shortlog = new LinkPanel("commitShortMessage", "list", + trimmedMessage, CommitPage.class, WicketUtils.newObjectParameter( + change.repository, commit.getName())); + if (!shortMessage.equals(trimmedMessage)) { + WicketUtils.setHtmlTooltip(shortlog, shortMessage); + } + commitItem.add(shortlog); + + // commit hash link + int hashLen = app().settings().getInteger(Keys.web.shortCommitIdLength, 6); + LinkPanel commitHash = new LinkPanel("hashLink", null, commit.getName().substring(0, hashLen), + CommitPage.class, WicketUtils.newObjectParameter( + change.repository, commit.getName())); + WicketUtils.setCssClass(commitHash, "shortsha1"); + WicketUtils.setHtmlTooltip(commitHash, commit.getName()); + commitItem.add(commitHash); + + if (showSwatch) { + // set repository color + String color = StringUtils.getColor(StringUtils.stripDotGit(change.repository)); + WicketUtils.setCssStyle(commitItem, MessageFormat.format("border-left: 2px solid {0};", color)); + } + } + }; + + logItem.add(commitsView); + } + }; + + add(pushView); + } + + public boolean hasMore() { + return hasMore; + } + + public boolean hideIfEmpty() { + setVisible(hasChanges); + return hasChanges; + } +} diff --git a/src/main/java/com/gitblit/wicket/panels/GravatarImage.java b/src/main/java/com/gitblit/wicket/panels/GravatarImage.java index 9507a25e..e4157577 100644 --- a/src/main/java/com/gitblit/wicket/panels/GravatarImage.java +++ b/src/main/java/com/gitblit/wicket/panels/GravatarImage.java @@ -1,70 +1,74 @@ -/*
- * Copyright 2011 gitblit.com.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.gitblit.wicket.panels;
-
-import org.eclipse.jgit.lib.PersonIdent;
-
-import com.gitblit.Keys;
-import com.gitblit.models.UserModel;
-import com.gitblit.utils.ActivityUtils;
-import com.gitblit.wicket.ExternalImage;
-import com.gitblit.wicket.WicketUtils;
-
-/**
- * Represents a Gravatar image.
- *
- * @author James Moger
- *
- */
-public class GravatarImage extends BasePanel {
-
- private static final long serialVersionUID = 1L;
-
- public GravatarImage(String id, PersonIdent person) {
- this(id, person, 0);
- }
-
- public GravatarImage(String id, PersonIdent person, int width) {
- this(id, person.getName(), person.getEmailAddress(), "gravatar", width, true);
- }
-
- public GravatarImage(String id, PersonIdent person, String cssClass, int width, boolean identicon) {
- this(id, person.getName(), person.getEmailAddress(), cssClass, width, identicon);
- }
-
- public GravatarImage(String id, UserModel user, String cssClass, int width, boolean identicon) {
- this(id, user.getDisplayName(), user.emailAddress, cssClass, width, identicon);
- }
-
- public GravatarImage(String id, String username, String emailaddress, String cssClass, int width, boolean identicon) {
- super(id);
-
- String email = emailaddress == null ? username.toLowerCase() : emailaddress.toLowerCase();
- String url;
- if (identicon) {
- url = ActivityUtils.getGravatarIdenticonUrl(email, width);
- } else {
- url = ActivityUtils.getGravatarThumbnailUrl(email, width);
- }
- ExternalImage image = new ExternalImage("image", url);
- if (cssClass != null) {
- WicketUtils.setCssClass(image, cssClass);
- }
- add(image);
- WicketUtils.setHtmlTooltip(image, username);
- setVisible(app().settings().getBoolean(Keys.web.allowGravatar, true));
- }
+/* + * Copyright 2011 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.wicket.panels; + +import org.eclipse.jgit.lib.PersonIdent; + +import com.gitblit.Keys; +import com.gitblit.models.UserModel; +import com.gitblit.utils.ActivityUtils; +import com.gitblit.wicket.ExternalImage; +import com.gitblit.wicket.WicketUtils; + +/** + * Represents a Gravatar image. + * + * @author James Moger + * + */ +public class GravatarImage extends BasePanel { + + private static final long serialVersionUID = 1L; + + public GravatarImage(String id, PersonIdent person) { + this(id, person, 0); + } + + public GravatarImage(String id, PersonIdent person, int width) { + this(id, person.getName(), person.getEmailAddress(), "gravatar", width, true); + } + + public GravatarImage(String id, PersonIdent person, String cssClass, int width, boolean identicon) { + this(id, person.getName(), person.getEmailAddress(), cssClass, width, identicon); + } + + public GravatarImage(String id, UserModel user, String cssClass, int width, boolean identicon) { + this(id, user.getDisplayName(), user.emailAddress, cssClass, width, identicon); + } + + public GravatarImage(String id, String username, String emailaddress, String cssClass, int width, boolean identicon) { + super(id); + + String email = emailaddress == null ? username.toLowerCase() : emailaddress.toLowerCase(); + String url; + if (identicon) { + url = ActivityUtils.getGravatarIdenticonUrl(email, width); + } else { + url = ActivityUtils.getGravatarThumbnailUrl(email, width); + } + ExternalImage image = new ExternalImage("image", url); + if (cssClass != null) { + WicketUtils.setCssClass(image, cssClass); + } + add(image); + WicketUtils.setHtmlTooltip(image, username); + setVisible(app().settings().getBoolean(Keys.web.allowGravatar, true)); + } + + public void setTooltip(String tooltip) { + WicketUtils.setHtmlTooltip(get("image"), tooltip); + } }
\ No newline at end of file diff --git a/src/main/java/com/gitblit/wicket/panels/MarkdownTextArea.java b/src/main/java/com/gitblit/wicket/panels/MarkdownTextArea.java new file mode 100644 index 00000000..fbce7892 --- /dev/null +++ b/src/main/java/com/gitblit/wicket/panels/MarkdownTextArea.java @@ -0,0 +1,118 @@ +/* + * Copyright 2013 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.wicket.panels; + +import org.apache.wicket.ajax.AbstractAjaxTimerBehavior; +import org.apache.wicket.ajax.AjaxRequestTarget; +import org.apache.wicket.ajax.form.AjaxFormComponentUpdatingBehavior; +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.html.form.TextArea; +import org.apache.wicket.model.IModel; +import org.apache.wicket.model.PropertyModel; +import org.apache.wicket.util.time.Duration; + +import com.gitblit.utils.MarkdownUtils; +import com.gitblit.wicket.GitBlitWebApp; + +public class MarkdownTextArea extends TextArea { + + private static final long serialVersionUID = 1L; + + protected String repositoryName; + + protected String text = ""; + + public MarkdownTextArea(String id, final IModel<String> previewModel, final Label previewLabel) { + super(id); + this.repositoryName = repositoryName; + setModel(new PropertyModel(this, "text")); + add(new AjaxFormComponentUpdatingBehavior("onblur") { + private static final long serialVersionUID = 1L; + + @Override + protected void onUpdate(AjaxRequestTarget target) { + renderPreview(previewModel); + if (target != null) { + target.addComponent(previewLabel); + } + } + }); + add(new AjaxFormComponentUpdatingBehavior("onchange") { + private static final long serialVersionUID = 1L; + + @Override + protected void onUpdate(AjaxRequestTarget target) { + renderPreview(previewModel); + if (target != null) { + target.addComponent(previewLabel); + } + } + }); + + add(new KeepAliveBehavior()); + setOutputMarkupId(true); + } + + protected void renderPreview(IModel<String> previewModel) { + if (text == null) { + return; + } + String html = MarkdownUtils.transformGFM(GitBlitWebApp.get().settings(), text, repositoryName); + previewModel.setObject(html); + } + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + public void setRepository(String repositoryName) { + this.repositoryName = repositoryName; + } + +// @Override +// protected void onBeforeRender() { +// super.onBeforeRender(); +// add(new RichTextSetActiveTextFieldAttributeModifier(this.getMarkupId())); +// } +// +// private class RichTextSetActiveTextFieldAttributeModifier extends AttributeModifier { +// +// private static final long serialVersionUID = 1L; +// +// public RichTextSetActiveTextFieldAttributeModifier(String markupId) { +// super("onClick", true, new Model("richTextSetActiveTextField('" + markupId + "');")); +// } +// } + + private class KeepAliveBehavior extends AbstractAjaxTimerBehavior { + + private static final long serialVersionUID = 1L; + + public KeepAliveBehavior() { + super(Duration.minutes(5)); + } + + @Override + protected void onTimer(AjaxRequestTarget target) { + // prevent wicket changing focus + target.focusComponent(null); + } + } +}
\ No newline at end of file diff --git a/src/main/java/com/gitblit/wicket/panels/ReflogPanel.html b/src/main/java/com/gitblit/wicket/panels/ReflogPanel.html index 8df28494..3a0b0b8c 100644 --- a/src/main/java/com/gitblit/wicket/panels/ReflogPanel.html +++ b/src/main/java/com/gitblit/wicket/panels/ReflogPanel.html @@ -12,7 +12,7 @@ <td class="icon hidden-phone"><i wicket:id="changeIcon"></i></td>
<td style="padding-left: 7px;vertical-align:middle;">
<div>
- <span class="when" wicket:id="whenChanged"></span> <span wicket:id="refRewind" class="alert alert-error" style="padding: 1px 5px;font-size: 10px;font-weight: bold;margin-left: 10px;">[rewind]</span>
+ <span class="when" wicket:id="whenChanged"></span> <span wicket:id="refRewind" class="aui-lozenge aui-lozenge-error">[rewind]</span>
</div>
<div style="font-weight:bold;"><span wicket:id="whoChanged">[change author]</span> <span wicket:id="whatChanged"></span> <span wicket:id="refChanged"></span> <span wicket:id="byAuthors"></span></div>
</td>
@@ -26,7 +26,7 @@ <td class="hidden-phone hidden-tablet" style="vertical-align:top;padding-left:7px;"><span wicket:id="commitAuthor"></span></td>
<td style="vertical-align:top;"><span wicket:id="hashLink" style="padding-left: 5px;">[hash link]</span></td>
<td style="vertical-align:top;padding-left:5px;"><img wicket:id="commitIcon" /></td>
- <td style="vertical-align:top;">
+ <td style="vertical-align:top;">
<span wicket:id="commitShortMessage">[commit short message]</span>
</td>
</tr>
diff --git a/src/main/java/com/gitblit/wicket/panels/ReflogPanel.java b/src/main/java/com/gitblit/wicket/panels/ReflogPanel.java index c1db726a..baefc6bd 100644 --- a/src/main/java/com/gitblit/wicket/panels/ReflogPanel.java +++ b/src/main/java/com/gitblit/wicket/panels/ReflogPanel.java @@ -1,313 +1,325 @@ -/*
- * Copyright 2013 gitblit.com.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.gitblit.wicket.panels;
-
-import java.text.DateFormat;
-import java.text.MessageFormat;
-import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Calendar;
-import java.util.Date;
-import java.util.List;
-import java.util.TimeZone;
-
-import org.apache.wicket.markup.html.basic.Label;
-import org.apache.wicket.markup.repeater.Item;
-import org.apache.wicket.markup.repeater.data.DataView;
-import org.apache.wicket.markup.repeater.data.ListDataProvider;
-import org.apache.wicket.model.StringResourceModel;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.ReceiveCommand.Type;
-
-import com.gitblit.Constants;
-import com.gitblit.Keys;
-import com.gitblit.models.RefLogEntry;
-import com.gitblit.models.RepositoryCommit;
-import com.gitblit.models.RepositoryModel;
-import com.gitblit.models.UserModel;
-import com.gitblit.utils.RefLogUtils;
-import com.gitblit.utils.StringUtils;
-import com.gitblit.utils.TimeUtils;
-import com.gitblit.wicket.WicketUtils;
-import com.gitblit.wicket.pages.CommitPage;
-import com.gitblit.wicket.pages.ComparePage;
-import com.gitblit.wicket.pages.ReflogPage;
-import com.gitblit.wicket.pages.TagPage;
-import com.gitblit.wicket.pages.TreePage;
-import com.gitblit.wicket.pages.UserPage;
-
-public class ReflogPanel extends BasePanel {
-
- private static final long serialVersionUID = 1L;
-
- private final boolean hasChanges;
-
- private boolean hasMore;
-
- public ReflogPanel(String wicketId, final RepositoryModel model, Repository r, int limit, int pageOffset) {
- super(wicketId);
- boolean pageResults = limit <= 0;
- int changesPerPage = app().settings().getInteger(Keys.web.reflogChangesPerPage, 10);
- if (changesPerPage <= 1) {
- changesPerPage = 10;
- }
-
- List<RefLogEntry> changes;
- if (pageResults) {
- changes = RefLogUtils.getLogByRef(model.name, r, pageOffset * changesPerPage, changesPerPage);
- } else {
- changes = RefLogUtils.getLogByRef(model.name, r, limit);
- }
-
- // inaccurate way to determine if there are more commits.
- // works unless commits.size() represents the exact end.
- hasMore = changes.size() >= changesPerPage;
- hasChanges = changes.size() > 0;
-
- setup(changes);
-
- // determine to show pager, more, or neither
- if (limit <= 0) {
- // no display limit
- add(new Label("moreChanges").setVisible(false));
- } else {
- if (pageResults) {
- // paging
- add(new Label("moreChanges").setVisible(false));
- } else {
- // more
- if (changes.size() == limit) {
- // show more
- add(new LinkPanel("moreChanges", "link", new StringResourceModel("gb.moreChanges",
- this, null), ReflogPage.class,
- WicketUtils.newRepositoryParameter(model.name)));
- } else {
- // no more
- add(new Label("moreChanges").setVisible(false));
- }
- }
- }
- }
-
- public ReflogPanel(String wicketId, List<RefLogEntry> changes) {
- super(wicketId);
- hasChanges = changes.size() > 0;
- setup(changes);
- add(new Label("moreChanges").setVisible(false));
- }
-
- protected void setup(List<RefLogEntry> changes) {
-
- ListDataProvider<RefLogEntry> dp = new ListDataProvider<RefLogEntry>(changes);
- DataView<RefLogEntry> changeView = new DataView<RefLogEntry>("change", dp) {
- private static final long serialVersionUID = 1L;
-
- @Override
- public void populateItem(final Item<RefLogEntry> changeItem) {
- final RefLogEntry change = changeItem.getModelObject();
-
- String dateFormat = app().settings().getString(Keys.web.datetimestampLongFormat, "EEEE, MMMM d, yyyy HH:mm Z");
- TimeZone timezone = getTimeZone();
- DateFormat df = new SimpleDateFormat(dateFormat);
- df.setTimeZone(timezone);
- Calendar cal = Calendar.getInstance(timezone);
-
- String fullRefName = change.getChangedRefs().get(0);
- String shortRefName = fullRefName;
- boolean isTag = false;
- if (shortRefName.startsWith(Constants.R_HEADS)) {
- shortRefName = shortRefName.substring(Constants.R_HEADS.length());
- } else if (shortRefName.startsWith(Constants.R_TAGS)) {
- shortRefName = shortRefName.substring(Constants.R_TAGS.length());
- isTag = true;
- }
-
- String fuzzydate;
- TimeUtils tu = getTimeUtils();
- Date changeDate = change.date;
- if (TimeUtils.isToday(changeDate, timezone)) {
- fuzzydate = tu.today();
- } else if (TimeUtils.isYesterday(changeDate, timezone)) {
- fuzzydate = tu.yesterday();
- } else {
- // calculate a fuzzy time ago date
- cal.setTime(changeDate);
- cal.set(Calendar.HOUR_OF_DAY, 0);
- cal.set(Calendar.MINUTE, 0);
- cal.set(Calendar.SECOND, 0);
- cal.set(Calendar.MILLISECOND, 0);
- Date date = cal.getTime();
- fuzzydate = getTimeUtils().timeAgo(date);
- }
- changeItem.add(new Label("whenChanged", fuzzydate + ", " + df.format(changeDate)));
-
- Label changeIcon = new Label("changeIcon");
- if (Type.DELETE.equals(change.getChangeType(fullRefName))) {
- WicketUtils.setCssClass(changeIcon, "iconic-trash-stroke");
- } else if (isTag) {
- WicketUtils.setCssClass(changeIcon, "iconic-tag");
- } else {
- WicketUtils.setCssClass(changeIcon, "iconic-upload");
- }
- changeItem.add(changeIcon);
-
- if (change.user.username.equals(change.user.emailAddress) && change.user.emailAddress.indexOf('@') > -1) {
- // username is an email address - 1.2.1 push log bug
- changeItem.add(new Label("whoChanged", change.user.getDisplayName()));
- } else if (change.user.username.equals(UserModel.ANONYMOUS.username)) {
- // anonymous change
- changeItem.add(new Label("whoChanged", getString("gb.anonymousUser")));
- } else {
- // link to user account page
- changeItem.add(new LinkPanel("whoChanged", null, change.user.getDisplayName(),
- UserPage.class, WicketUtils.newUsernameParameter(change.user.username)));
- }
-
- boolean isDelete = false;
- boolean isRewind = false;
- String what;
- String by = null;
- switch(change.getChangeType(fullRefName)) {
- case CREATE:
- if (isTag) {
- // new tag
- what = getString("gb.pushedNewTag");
- } else {
- // new branch
- what = getString("gb.pushedNewBranch");
- }
- break;
- case DELETE:
- isDelete = true;
- if (isTag) {
- what = getString("gb.deletedTag");
- } else {
- what = getString("gb.deletedBranch");
- }
- break;
- case UPDATE_NONFASTFORWARD:
- isRewind = true;
- default:
- what = MessageFormat.format(change.getCommitCount() > 1 ? getString("gb.pushedNCommitsTo") : getString("gb.pushedOneCommitTo") , change.getCommitCount());
-
- if (change.getAuthorCount() == 1) {
- by = MessageFormat.format(getString("gb.byOneAuthor"), change.getAuthorIdent().getName());
- } else {
- by = MessageFormat.format(getString("gb.byNAuthors"), change.getAuthorCount());
- }
- break;
- }
- changeItem.add(new Label("whatChanged", what));
- changeItem.add(new Label("byAuthors", by).setVisible(!StringUtils.isEmpty(by)));
-
- changeItem.add(new Label("refRewind", getString("gb.rewind")).setVisible(isRewind));
-
- if (isDelete) {
- // can't link to deleted ref
- changeItem.add(new Label("refChanged", shortRefName));
- } else if (isTag) {
- // link to tag
- changeItem.add(new LinkPanel("refChanged", null, shortRefName,
- TagPage.class, WicketUtils.newObjectParameter(change.repository, fullRefName)));
- } else {
- // link to tree
- changeItem.add(new LinkPanel("refChanged", null, shortRefName,
- TreePage.class, WicketUtils.newObjectParameter(change.repository, fullRefName)));
- }
-
- int maxCommitCount = 5;
- List<RepositoryCommit> commits = change.getCommits();
- if (commits.size() > maxCommitCount) {
- commits = new ArrayList<RepositoryCommit>(commits.subList(0, maxCommitCount));
- }
-
- // compare link
- String compareLinkText = null;
- if ((change.getCommitCount() <= maxCommitCount) && (change.getCommitCount() > 1)) {
- compareLinkText = MessageFormat.format(getString("gb.viewComparison"), commits.size());
- } else if (change.getCommitCount() > maxCommitCount) {
- int diff = change.getCommitCount() - maxCommitCount;
- compareLinkText = MessageFormat.format(diff > 1 ? getString("gb.nMoreCommits") : getString("gb.oneMoreCommit"), diff);
- }
- if (StringUtils.isEmpty(compareLinkText)) {
- changeItem.add(new Label("compareLink").setVisible(false));
- } else {
- String endRangeId = change.getNewId(fullRefName);
- String startRangeId = change.getOldId(fullRefName);
- changeItem.add(new LinkPanel("compareLink", null, compareLinkText, ComparePage.class, WicketUtils.newRangeParameter(change.repository, startRangeId, endRangeId)));
- }
-
- ListDataProvider<RepositoryCommit> cdp = new ListDataProvider<RepositoryCommit>(commits);
- DataView<RepositoryCommit> commitsView = new DataView<RepositoryCommit>("commit", cdp) {
- private static final long serialVersionUID = 1L;
-
- @Override
- public void populateItem(final Item<RepositoryCommit> commitItem) {
- final RepositoryCommit commit = commitItem.getModelObject();
-
- // author gravatar
- commitItem.add(new GravatarImage("commitAuthor", commit.getAuthorIdent(), null, 16, false));
-
- // merge icon
- if (commit.getParentCount() > 1) {
- commitItem.add(WicketUtils.newImage("commitIcon", "commit_merge_16x16.png"));
- } else {
- commitItem.add(WicketUtils.newBlankImage("commitIcon"));
- }
-
- // short message
- String shortMessage = commit.getShortMessage();
- String trimmedMessage = shortMessage;
- if (commit.getRefs() != null && commit.getRefs().size() > 0) {
- trimmedMessage = StringUtils.trimString(shortMessage, Constants.LEN_SHORTLOG_REFS);
- } else {
- trimmedMessage = StringUtils.trimString(shortMessage, Constants.LEN_SHORTLOG);
- }
- LinkPanel shortlog = new LinkPanel("commitShortMessage", "list",
- trimmedMessage, CommitPage.class, WicketUtils.newObjectParameter(
- change.repository, commit.getName()));
- if (!shortMessage.equals(trimmedMessage)) {
- WicketUtils.setHtmlTooltip(shortlog, shortMessage);
- }
- commitItem.add(shortlog);
-
- // commit hash link
- int hashLen = app().settings().getInteger(Keys.web.shortCommitIdLength, 6);
- LinkPanel commitHash = new LinkPanel("hashLink", null, commit.getName().substring(0, hashLen),
- CommitPage.class, WicketUtils.newObjectParameter(
- change.repository, commit.getName()));
- WicketUtils.setCssClass(commitHash, "shortsha1");
- WicketUtils.setHtmlTooltip(commitHash, commit.getName());
- commitItem.add(commitHash);
- }
- };
-
- changeItem.add(commitsView);
- }
- };
-
- add(changeView);
- }
-
- public boolean hasMore() {
- return hasMore;
- }
-
- public boolean hideIfEmpty() {
- setVisible(hasChanges);
- return hasChanges;
- }
-}
+/* + * Copyright 2013 gitblit.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gitblit.wicket.panels; + +import java.text.DateFormat; +import java.text.MessageFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.List; +import java.util.TimeZone; + +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.repeater.Item; +import org.apache.wicket.markup.repeater.data.DataView; +import org.apache.wicket.markup.repeater.data.ListDataProvider; +import org.apache.wicket.model.StringResourceModel; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.transport.ReceiveCommand.Type; + +import com.gitblit.Constants; +import com.gitblit.Keys; +import com.gitblit.models.RefLogEntry; +import com.gitblit.models.RepositoryCommit; +import com.gitblit.models.RepositoryModel; +import com.gitblit.models.UserModel; +import com.gitblit.utils.RefLogUtils; +import com.gitblit.utils.StringUtils; +import com.gitblit.utils.TimeUtils; +import com.gitblit.wicket.WicketUtils; +import com.gitblit.wicket.pages.CommitPage; +import com.gitblit.wicket.pages.ComparePage; +import com.gitblit.wicket.pages.ReflogPage; +import com.gitblit.wicket.pages.TagPage; +import com.gitblit.wicket.pages.TicketsPage; +import com.gitblit.wicket.pages.TreePage; +import com.gitblit.wicket.pages.UserPage; + +public class ReflogPanel extends BasePanel { + + private static final long serialVersionUID = 1L; + + private final boolean hasChanges; + + private boolean hasMore; + + public ReflogPanel(String wicketId, final RepositoryModel model, Repository r, int limit, int pageOffset) { + super(wicketId); + boolean pageResults = limit <= 0; + int changesPerPage = app().settings().getInteger(Keys.web.reflogChangesPerPage, 10); + if (changesPerPage <= 1) { + changesPerPage = 10; + } + + List<RefLogEntry> changes; + if (pageResults) { + changes = RefLogUtils.getLogByRef(model.name, r, pageOffset * changesPerPage, changesPerPage); + } else { + changes = RefLogUtils.getLogByRef(model.name, r, limit); + } + + // inaccurate way to determine if there are more commits. + // works unless commits.size() represents the exact end. + hasMore = changes.size() >= changesPerPage; + hasChanges = changes.size() > 0; + + setup(changes); + + // determine to show pager, more, or neither + if (limit <= 0) { + // no display limit + add(new Label("moreChanges").setVisible(false)); + } else { + if (pageResults) { + // paging + add(new Label("moreChanges").setVisible(false)); + } else { + // more + if (changes.size() == limit) { + // show more + add(new LinkPanel("moreChanges", "link", new StringResourceModel("gb.moreChanges", + this, null), ReflogPage.class, + WicketUtils.newRepositoryParameter(model.name))); + } else { + // no more + add(new Label("moreChanges").setVisible(false)); + } + } + } + } + + public ReflogPanel(String wicketId, List<RefLogEntry> changes) { + super(wicketId); + hasChanges = changes.size() > 0; + setup(changes); + add(new Label("moreChanges").setVisible(false)); + } + + protected void setup(List<RefLogEntry> changes) { + + ListDataProvider<RefLogEntry> dp = new ListDataProvider<RefLogEntry>(changes); + DataView<RefLogEntry> changeView = new DataView<RefLogEntry>("change", dp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item<RefLogEntry> changeItem) { + final RefLogEntry change = changeItem.getModelObject(); + + String dateFormat = app().settings().getString(Keys.web.datetimestampLongFormat, "EEEE, MMMM d, yyyy HH:mm Z"); + TimeZone timezone = getTimeZone(); + DateFormat df = new SimpleDateFormat(dateFormat); + df.setTimeZone(timezone); + Calendar cal = Calendar.getInstance(timezone); + + String fullRefName = change.getChangedRefs().get(0); + String shortRefName = fullRefName; + String ticketId = null; + boolean isTag = false; + boolean isTicket = false; + if (shortRefName.startsWith(Constants.R_TICKET)) { + ticketId = fullRefName.substring(Constants.R_TICKET.length()); + shortRefName = MessageFormat.format(getString("gb.ticketN"), ticketId); + isTicket = true; + } else if (shortRefName.startsWith(Constants.R_HEADS)) { + shortRefName = shortRefName.substring(Constants.R_HEADS.length()); + } else if (shortRefName.startsWith(Constants.R_TAGS)) { + shortRefName = shortRefName.substring(Constants.R_TAGS.length()); + isTag = true; + } + + String fuzzydate; + TimeUtils tu = getTimeUtils(); + Date changeDate = change.date; + if (TimeUtils.isToday(changeDate, timezone)) { + fuzzydate = tu.today(); + } else if (TimeUtils.isYesterday(changeDate, timezone)) { + fuzzydate = tu.yesterday(); + } else { + // calculate a fuzzy time ago date + cal.setTime(changeDate); + cal.set(Calendar.HOUR_OF_DAY, 0); + cal.set(Calendar.MINUTE, 0); + cal.set(Calendar.SECOND, 0); + cal.set(Calendar.MILLISECOND, 0); + Date date = cal.getTime(); + fuzzydate = getTimeUtils().timeAgo(date); + } + changeItem.add(new Label("whenChanged", fuzzydate + ", " + df.format(changeDate))); + + Label changeIcon = new Label("changeIcon"); + if (Type.DELETE.equals(change.getChangeType(fullRefName))) { + WicketUtils.setCssClass(changeIcon, "iconic-trash-stroke"); + } else if (isTag) { + WicketUtils.setCssClass(changeIcon, "iconic-tag"); + } else if (isTicket) { + WicketUtils.setCssClass(changeIcon, "fa fa-ticket"); + } else { + WicketUtils.setCssClass(changeIcon, "iconic-upload"); + } + changeItem.add(changeIcon); + + if (change.user.username.equals(change.user.emailAddress) && change.user.emailAddress.indexOf('@') > -1) { + // username is an email address - 1.2.1 push log bug + changeItem.add(new Label("whoChanged", change.user.getDisplayName())); + } else if (change.user.username.equals(UserModel.ANONYMOUS.username)) { + // anonymous change + changeItem.add(new Label("whoChanged", getString("gb.anonymousUser"))); + } else { + // link to user account page + changeItem.add(new LinkPanel("whoChanged", null, change.user.getDisplayName(), + UserPage.class, WicketUtils.newUsernameParameter(change.user.username))); + } + + boolean isDelete = false; + boolean isRewind = false; + String what; + String by = null; + switch(change.getChangeType(fullRefName)) { + case CREATE: + if (isTag) { + // new tag + what = getString("gb.pushedNewTag"); + } else { + // new branch + what = getString("gb.pushedNewBranch"); + } + break; + case DELETE: + isDelete = true; + if (isTag) { + what = getString("gb.deletedTag"); + } else { + what = getString("gb.deletedBranch"); + } + break; + case UPDATE_NONFASTFORWARD: + isRewind = true; + default: + what = MessageFormat.format(change.getCommitCount() > 1 ? getString("gb.pushedNCommitsTo") : getString("gb.pushedOneCommitTo"), change.getCommitCount()); + + if (change.getAuthorCount() == 1) { + by = MessageFormat.format(getString("gb.byOneAuthor"), change.getAuthorIdent().getName()); + } else { + by = MessageFormat.format(getString("gb.byNAuthors"), change.getAuthorCount()); + } + break; + } + changeItem.add(new Label("whatChanged", what)); + changeItem.add(new Label("byAuthors", by).setVisible(!StringUtils.isEmpty(by))); + changeItem.add(new Label("refRewind", getString("gb.rewind")).setVisible(isRewind)); + + if (isDelete) { + // can't link to deleted ref + changeItem.add(new Label("refChanged", shortRefName)); + } else if (isTag) { + // link to tag + changeItem.add(new LinkPanel("refChanged", null, shortRefName, + TagPage.class, WicketUtils.newObjectParameter(change.repository, fullRefName))); + } else if (isTicket) { + // link to ticket + changeItem.add(new LinkPanel("refChanged", null, shortRefName, + TicketsPage.class, WicketUtils.newObjectParameter(change.repository, ticketId))); + } else { + // link to tree + changeItem.add(new LinkPanel("refChanged", null, shortRefName, + TreePage.class, WicketUtils.newObjectParameter(change.repository, fullRefName))); + } + + int maxCommitCount = 5; + List<RepositoryCommit> commits = change.getCommits(); + if (commits.size() > maxCommitCount) { + commits = new ArrayList<RepositoryCommit>(commits.subList(0, maxCommitCount)); + } + + // compare link + String compareLinkText = null; + if ((change.getCommitCount() <= maxCommitCount) && (change.getCommitCount() > 1)) { + compareLinkText = MessageFormat.format(getString("gb.viewComparison"), commits.size()); + } else if (change.getCommitCount() > maxCommitCount) { + int diff = change.getCommitCount() - maxCommitCount; + compareLinkText = MessageFormat.format(diff > 1 ? getString("gb.nMoreCommits") : getString("gb.oneMoreCommit"), diff); + } + if (StringUtils.isEmpty(compareLinkText)) { + changeItem.add(new Label("compareLink").setVisible(false)); + } else { + String endRangeId = change.getNewId(fullRefName); + String startRangeId = change.getOldId(fullRefName); + changeItem.add(new LinkPanel("compareLink", null, compareLinkText, ComparePage.class, WicketUtils.newRangeParameter(change.repository, startRangeId, endRangeId))); + } + + ListDataProvider<RepositoryCommit> cdp = new ListDataProvider<RepositoryCommit>(commits); + DataView<RepositoryCommit> commitsView = new DataView<RepositoryCommit>("commit", cdp) { + private static final long serialVersionUID = 1L; + + @Override + public void populateItem(final Item<RepositoryCommit> commitItem) { + final RepositoryCommit commit = commitItem.getModelObject(); + + // author gravatar + commitItem.add(new GravatarImage("commitAuthor", commit.getAuthorIdent(), null, 16, false)); + + // merge icon + if (commit.getParentCount() > 1) { + commitItem.add(WicketUtils.newImage("commitIcon", "commit_merge_16x16.png")); + } else { + commitItem.add(WicketUtils.newBlankImage("commitIcon")); + } + + // short message + String shortMessage = commit.getShortMessage(); + String trimmedMessage = shortMessage; + if (commit.getRefs() != null && commit.getRefs().size() > 0) { + trimmedMessage = StringUtils.trimString(shortMessage, Constants.LEN_SHORTLOG_REFS); + } else { + trimmedMessage = StringUtils.trimString(shortMessage, Constants.LEN_SHORTLOG); + } + LinkPanel shortlog = new LinkPanel("commitShortMessage", "list", + trimmedMessage, CommitPage.class, WicketUtils.newObjectParameter( + change.repository, commit.getName())); + if (!shortMessage.equals(trimmedMessage)) { + WicketUtils.setHtmlTooltip(shortlog, shortMessage); + } + commitItem.add(shortlog); + + // commit hash link + int hashLen = app().settings().getInteger(Keys.web.shortCommitIdLength, 6); + LinkPanel commitHash = new LinkPanel("hashLink", null, commit.getName().substring(0, hashLen), + CommitPage.class, WicketUtils.newObjectParameter( + change.repository, commit.getName())); + WicketUtils.setCssClass(commitHash, "shortsha1"); + WicketUtils.setHtmlTooltip(commitHash, commit.getName()); + commitItem.add(commitHash); + } + }; + + changeItem.add(commitsView); + } + }; + + add(changeView); + } + + public boolean hasMore() { + return hasMore; + } + + public boolean hideIfEmpty() { + setVisible(hasChanges); + return hasChanges; + } +} diff --git a/src/main/java/com/gitblit/wicket/panels/RefsPanel.java b/src/main/java/com/gitblit/wicket/panels/RefsPanel.java index 7a16f4a2..6e9e866e 100644 --- a/src/main/java/com/gitblit/wicket/panels/RefsPanel.java +++ b/src/main/java/com/gitblit/wicket/panels/RefsPanel.java @@ -25,7 +25,6 @@ import java.util.Map; import org.apache.wicket.Component;
import org.apache.wicket.markup.html.WebPage;
import org.apache.wicket.markup.html.basic.Label;
-import org.apache.wicket.markup.html.panel.Panel;
import org.apache.wicket.markup.repeater.Item;
import org.apache.wicket.markup.repeater.data.DataView;
import org.apache.wicket.markup.repeater.data.ListDataProvider;
@@ -34,13 +33,15 @@ import org.eclipse.jgit.revwalk.RevCommit; import com.gitblit.Constants;
import com.gitblit.models.RefModel;
+import com.gitblit.models.RepositoryModel;
import com.gitblit.utils.StringUtils;
import com.gitblit.wicket.WicketUtils;
import com.gitblit.wicket.pages.CommitPage;
import com.gitblit.wicket.pages.LogPage;
import com.gitblit.wicket.pages.TagPage;
+import com.gitblit.wicket.pages.TicketsPage;
-public class RefsPanel extends Panel {
+public class RefsPanel extends BasePanel {
private static final long serialVersionUID = 1L;
@@ -88,6 +89,8 @@ public class RefsPanel extends Panel { }
}
final boolean shouldBreak = remoteCount < refs.size();
+ RepositoryModel repository = app().repositories().getRepositoryModel(repositoryName);
+ final boolean hasTickets = app().tickets().hasTickets(repository);
ListDataProvider<RefModel> refsDp = new ListDataProvider<RefModel>(refs);
DataView<RefModel> refsView = new DataView<RefModel>("ref", refsDp) {
@@ -103,7 +106,13 @@ public class RefsPanel extends Panel { Class<? extends WebPage> linkClass = CommitPage.class;
String cssClass = "";
String tooltip = "";
- if (name.startsWith(Constants.R_HEADS)) {
+ if (name.startsWith(Constants.R_TICKET)) {
+ // Gitblit ticket ref
+ objectid = name.substring(Constants.R_TICKET.length());
+ name = name.substring(Constants.R_HEADS.length());
+ linkClass = TicketsPage.class;
+ cssClass = "localBranch";
+ } else if (name.startsWith(Constants.R_HEADS)) {
// local branch
linkClass = LogPage.class;
name = name.substring(Constants.R_HEADS.length());
@@ -113,13 +122,23 @@ public class RefsPanel extends Panel { linkClass = LogPage.class;
cssClass = "headRef";
} else if (name.startsWith(Constants.R_CHANGES)) {
- // Gerrit change ref
+ // Gitblit change ref
name = name.substring(Constants.R_CHANGES.length());
// strip leading nn/ from nn/#####nn/ps = #####nn-ps
name = name.substring(name.indexOf('/') + 1).replace('/', '-');
String [] values = name.split("-");
+ // Gerrit change
tooltip = MessageFormat.format(getString("gb.reviewPatchset"), values[0], values[1]);
cssClass = "otherRef";
+ } else if (name.startsWith(Constants.R_TICKETS_PATCHSETS)) {
+ // Gitblit patchset ref
+ name = name.substring(Constants.R_TICKETS_PATCHSETS.length());
+ // strip leading nn/ from nn/#####nn/ps = #####nn-ps
+ name = name.substring(name.indexOf('/') + 1).replace('/', '-');
+ String [] values = name.split("-");
+ tooltip = MessageFormat.format(getString("gb.ticketPatchset"), values[0], values[1]);
+ linkClass = LogPage.class;
+ cssClass = "otherRef";
} else if (name.startsWith(Constants.R_PULL)) {
// Pull Request ref
String num = name.substring(Constants.R_PULL.length());
|