summaryrefslogtreecommitdiffstats
path: root/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'src/main')
-rw-r--r--src/main/distrib/data/clientapps.json12
-rw-r--r--src/main/distrib/data/gitblit.properties70
-rw-r--r--src/main/distrib/linux/reindex-tickets.sh15
-rw-r--r--src/main/distrib/win/reindex-tickets.cmd13
-rw-r--r--src/main/java/WEB-INF/web.xml16
-rw-r--r--src/main/java/com/gitblit/Constants.java8
-rw-r--r--src/main/java/com/gitblit/GitBlit.java154
-rw-r--r--src/main/java/com/gitblit/ReindexTickets.java183
-rw-r--r--src/main/java/com/gitblit/client/EditRepositoryDialog.java21
-rw-r--r--src/main/java/com/gitblit/git/GitblitReceivePack.java11
-rw-r--r--src/main/java/com/gitblit/git/GitblitReceivePackFactory.java11
-rw-r--r--src/main/java/com/gitblit/git/PatchsetCommand.java324
-rw-r--r--src/main/java/com/gitblit/git/PatchsetReceivePack.java1129
-rw-r--r--src/main/java/com/gitblit/manager/GitblitManager.java10
-rw-r--r--src/main/java/com/gitblit/manager/IGitblit.java8
-rw-r--r--src/main/java/com/gitblit/manager/RepositoryManager.java12
-rw-r--r--src/main/java/com/gitblit/models/RepositoryModel.java11
-rw-r--r--src/main/java/com/gitblit/models/TicketModel.java1286
-rw-r--r--src/main/java/com/gitblit/models/UserModel.java12
-rw-r--r--src/main/java/com/gitblit/servlet/PtServlet.java201
-rw-r--r--src/main/java/com/gitblit/tickets/BranchTicketService.java799
-rw-r--r--src/main/java/com/gitblit/tickets/FileTicketService.java467
-rw-r--r--src/main/java/com/gitblit/tickets/ITicketService.java1088
-rw-r--r--src/main/java/com/gitblit/tickets/NullTicketService.java129
-rw-r--r--src/main/java/com/gitblit/tickets/QueryBuilder.java222
-rw-r--r--src/main/java/com/gitblit/tickets/QueryResult.java114
-rw-r--r--src/main/java/com/gitblit/tickets/RedisTicketService.java534
-rw-r--r--src/main/java/com/gitblit/tickets/TicketIndexer.java657
-rw-r--r--src/main/java/com/gitblit/tickets/TicketLabel.java77
-rw-r--r--src/main/java/com/gitblit/tickets/TicketMilestone.java53
-rw-r--r--src/main/java/com/gitblit/tickets/TicketNotifier.java617
-rw-r--r--src/main/java/com/gitblit/tickets/TicketResponsible.java59
-rw-r--r--src/main/java/com/gitblit/tickets/TicketSerializer.java175
-rw-r--r--src/main/java/com/gitblit/tickets/commands.md11
-rw-r--r--src/main/java/com/gitblit/tickets/email.css38
-rw-r--r--src/main/java/com/gitblit/utils/JGitUtils.java207
-rw-r--r--src/main/java/com/gitblit/utils/JsonUtils.java4
-rw-r--r--src/main/java/com/gitblit/utils/MarkdownUtils.java4
-rw-r--r--src/main/java/com/gitblit/utils/RefLogUtils.java40
-rw-r--r--src/main/java/com/gitblit/wicket/GitBlitWebApp.java15
-rw-r--r--src/main/java/com/gitblit/wicket/GitBlitWebApp.properties141
-rw-r--r--src/main/java/com/gitblit/wicket/pages/BasePage.java25
-rw-r--r--src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.html31
-rw-r--r--src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.java11
-rw-r--r--src/main/java/com/gitblit/wicket/pages/EditTicketPage.html66
-rw-r--r--src/main/java/com/gitblit/wicket/pages/EditTicketPage.java290
-rw-r--r--src/main/java/com/gitblit/wicket/pages/ExportTicketPage.java82
-rw-r--r--src/main/java/com/gitblit/wicket/pages/NewTicketPage.html66
-rw-r--r--src/main/java/com/gitblit/wicket/pages/NewTicketPage.java202
-rw-r--r--src/main/java/com/gitblit/wicket/pages/NoTicketsPage.html21
-rw-r--r--src/main/java/com/gitblit/wicket/pages/NoTicketsPage.java44
-rw-r--r--src/main/java/com/gitblit/wicket/pages/RepositoryPage.html1
-rw-r--r--src/main/java/com/gitblit/wicket/pages/RepositoryPage.java20
-rw-r--r--src/main/java/com/gitblit/wicket/pages/TicketBasePage.java124
-rw-r--r--src/main/java/com/gitblit/wicket/pages/TicketPage.html577
-rw-r--r--src/main/java/com/gitblit/wicket/pages/TicketPage.java1527
-rw-r--r--src/main/java/com/gitblit/wicket/pages/TicketsPage.html215
-rw-r--r--src/main/java/com/gitblit/wicket/pages/TicketsPage.java878
-rw-r--r--src/main/java/com/gitblit/wicket/pages/propose_git.md6
-rw-r--r--src/main/java/com/gitblit/wicket/pages/propose_pt.md5
-rw-r--r--src/main/java/com/gitblit/wicket/panels/CommentPanel.html29
-rw-r--r--src/main/java/com/gitblit/wicket/panels/CommentPanel.java110
-rw-r--r--src/main/java/com/gitblit/wicket/panels/DigestsPanel.java539
-rw-r--r--src/main/java/com/gitblit/wicket/panels/GravatarImage.java142
-rw-r--r--src/main/java/com/gitblit/wicket/panels/MarkdownTextArea.java118
-rw-r--r--src/main/java/com/gitblit/wicket/panels/ReflogPanel.html4
-rw-r--r--src/main/java/com/gitblit/wicket/panels/ReflogPanel.java638
-rw-r--r--src/main/java/com/gitblit/wicket/panels/RefsPanel.java27
-rw-r--r--src/main/java/pt.cmd1
-rw-r--r--src/main/java/pt.py701
-rw-r--r--src/main/java/pt.txt49
-rw-r--r--src/main/resources/barnum_32x32.pngbin0 -> 1436 bytes
-rw-r--r--src/main/resources/gitblit.css414
73 files changed, 15231 insertions, 690 deletions
diff --git a/src/main/distrib/data/clientapps.json b/src/main/distrib/data/clientapps.json
index 2b15cd38..31e53efd 100644
--- a/src/main/distrib/data/clientapps.json
+++ b/src/main/distrib/data/clientapps.json
@@ -10,6 +10,17 @@
"isActive": true
},
{
+ "name": "Barnum",
+ "title": "Barnum",
+ "description": "a command-line Git companion for Gitblit Tickets",
+ "legal": "released under the Apache 2.0 License",
+ "command": "pt clone ${repoUrl}",
+ "productUrl": "http://barnum.gitblit.com",
+ "transports": [ "ssh" ],
+ "icon": "barnum_32x32.png",
+ "isActive": false
+ },
+ {
"name": "SmartGit/Hg",
"title": "syntevo SmartGit/Hg\u2122",
"description": "a Git client for Windows, Mac, & Linux",
@@ -73,6 +84,7 @@
"legal": "released under the GPLv3 open source license",
"cloneUrl": "sparkleshare://addProject/${baseUrl}/sparkleshare/${repoUrl}.xml",
"productUrl": "http://sparkleshare.org",
+ "transports": [ "ssh" ],
"platforms": [ "windows", "macintosh", "linux" ],
"icon": "sparkleshare_32x32.png",
"minimumPermission" : "RW+",
diff --git a/src/main/distrib/data/gitblit.properties b/src/main/distrib/data/gitblit.properties
index 5a083264..73c6ebd5 100644
--- a/src/main/distrib/data/gitblit.properties
+++ b/src/main/distrib/data/gitblit.properties
@@ -429,6 +429,76 @@ git.streamFileThreshold = 50m
# RESTART REQUIRED
git.packedGitMmap = false
+# Use the Gitblit patch receive pack for processing contributions and tickets.
+# This allows the user to push a patch using the familiar Gerrit syntax:
+#
+# git push <remote> HEAD:refs/for/<targetBranch>
+#
+# NOTE:
+# This requires git.enableGitServlet = true AND it requires an authenticated
+# git transport connection (http/https) when pushing from a client.
+#
+# Valid services include:
+# com.gitblit.tickets.FileTicketService
+# com.gitblit.tickets.BranchTicketService
+# com.gitblit.tickets.RedisTicketService
+#
+# SINCE 1.4.0
+# RESTART REQUIRED
+tickets.service =
+
+# Globally enable or disable creation of new bug, enhancement, task, etc tickets
+# for all repositories.
+#
+# If false, no tickets can be created through the ui for any repositories.
+# If true, each repository can control if they allow new tickets to be created.
+#
+# NOTE:
+# If a repository is accepting patchsets, new proposal tickets can be created
+# regardless of this setting.
+#
+# SINCE 1.4.0
+tickets.acceptNewTickets = true
+
+# Globally enable or disable pushing patchsets to all repositories.
+#
+# If false, no patchsets will be accepted for any repositories.
+# If true, each repository can control if they accept new patchsets.
+#
+# NOTE:
+# If a repository is accepting patchsets, new proposal tickets can be created
+# regardless of the acceptNewTickets setting.
+#
+# SINCE 1.4.0
+tickets.acceptNewPatchsets = true
+
+# Default setting to control patchset merge through the web ui. If true, patchsets
+# must have an approval score to enable the merge button. This setting can be
+# overriden per-repository.
+#
+# SINCE 1.4.0
+tickets.requireApproval = false
+
+# Specify the location of the Lucene Ticket index
+#
+# SINCE 1.4.0
+# RESTART REQUIRED
+tickets.indexFolder = ${baseFolder}/tickets/lucene
+
+# Define the url for the Redis server.
+#
+# e.g. redis://localhost:6379
+# redis://:foobared@localhost:6379/2
+#
+# SINCE 1.4.0
+# RESTART REQUIRED
+tickets.redis.url =
+
+# The number of tickets to display on a page.
+#
+# SINCE 1.4.0
+tickets.perPage = 25
+
#
# Groovy Integration
#
diff --git a/src/main/distrib/linux/reindex-tickets.sh b/src/main/distrib/linux/reindex-tickets.sh
new file mode 100644
index 00000000..15939291
--- /dev/null
+++ b/src/main/distrib/linux/reindex-tickets.sh
@@ -0,0 +1,15 @@
+#!/bin/bash
+# --------------------------------------------------------------------------
+# This is for reindexing Tickets with Lucene.
+#
+# Since the Tickets feature is undergoing massive churn it may be necessary
+# to reindex tickets due to model or index changes.
+#
+# usage:
+#
+# reindex-tickets.sh <baseFolder>
+#
+# --------------------------------------------------------------------------
+
+java -cp gitblit.jar:./ext/* com.gitblit.ReindexTickets --baseFolder $1
+
diff --git a/src/main/distrib/win/reindex-tickets.cmd b/src/main/distrib/win/reindex-tickets.cmd
new file mode 100644
index 00000000..e28f45f5
--- /dev/null
+++ b/src/main/distrib/win/reindex-tickets.cmd
@@ -0,0 +1,13 @@
+@REM --------------------------------------------------------------------------
+@REM This is for reindexing Tickets with Lucene.
+@REM
+@REM Since the Tickets feature is undergoing massive churn it may be necessary
+@REM to reindex tickets due to model or index changes.
+@REM
+@REM Always use forward-slashes for the path separator in your parameters!!
+@REM
+@REM Set FOLDER to the baseFolder.
+@REM --------------------------------------------------------------------------
+@SET FOLDER=data
+
+@java -cp gitblit.jar;"%CD%\ext\*" com.gitblit.ReindexTickets --baseFolder %FOLDER%
diff --git a/src/main/java/WEB-INF/web.xml b/src/main/java/WEB-INF/web.xml
index 8f51c21b..1451ec63 100644
--- a/src/main/java/WEB-INF/web.xml
+++ b/src/main/java/WEB-INF/web.xml
@@ -161,6 +161,20 @@
<url-pattern>/logo.png</url-pattern>
</servlet-mapping>
+
+ <!-- PT Servlet
+ <url-pattern> MUST match:
+ * Wicket Filter ignorePaths parameter -->
+ <servlet>
+ <servlet-name>PtServlet</servlet-name>
+ <servlet-class>com.gitblit.servlet.PtServlet</servlet-class>
+ </servlet>
+ <servlet-mapping>
+ <servlet-name>PtServlet</servlet-name>
+ <url-pattern>/pt</url-pattern>
+ </servlet-mapping>
+
+
<!-- Branch Graph Servlet
<url-pattern> MUST match:
* Wicket Filter ignorePaths parameter -->
@@ -300,7 +314,7 @@
* PagesFilter <url-pattern>
* PagesServlet <url-pattern>
* com.gitblit.Constants.PAGES_PATH -->
- <param-value>r/,git/,feed/,zip/,federation/,rpc/,pages/,robots.txt,logo.png,graph/,sparkleshare/</param-value>
+ <param-value>r/,git/,pt,feed/,zip/,federation/,rpc/,pages/,robots.txt,logo.png,graph/,sparkleshare/</param-value>
</init-param>
</filter>
<filter-mapping>
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("|", "&#124;");
+ }
+ 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("|", "&#124;")));
+ }
+ 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" /> &nbsp;<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" /> &nbsp;<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" /> &nbsp;<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" /> &nbsp;<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" /> &nbsp;<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" /> &nbsp;<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" /> &nbsp;<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" /> &nbsp;<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" /> &nbsp;<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" /> &nbsp;<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" /> &nbsp;<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" /> &nbsp;<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" /> &nbsp;<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" /> &nbsp;<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" /> &nbsp;<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" /> &nbsp;<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" /> &nbsp;<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" /> &nbsp;<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" /> &nbsp;<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" /> &nbsp;<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" /> &nbsp;<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" /> &nbsp; <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" /> &nbsp; <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", "&nbsp;").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());
diff --git a/src/main/java/pt.cmd b/src/main/java/pt.cmd
new file mode 100644
index 00000000..cec7e5f0
--- /dev/null
+++ b/src/main/java/pt.cmd
@@ -0,0 +1 @@
+@python %~dp0pt.py %1 %2 %3 %4 %5 %6 %7 %8 %9
diff --git a/src/main/java/pt.py b/src/main/java/pt.py
new file mode 100644
index 00000000..f1fe27f8
--- /dev/null
+++ b/src/main/java/pt.py
@@ -0,0 +1,701 @@
+#!/usr/bin/env python3
+#
+# Barnum, a Patchset Tool (pt)
+#
+# This Git wrapper script is designed to reduce the ceremony of working with Gitblit patchsets.
+#
+# 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.
+#
+#
+# Usage:
+#
+# pt fetch <id> [-p,--patchset <n>]
+# pt checkout <id> [-p,--patchset <n>] [-f,--force]
+# pt pull <id> [-p,--patchset <n>]
+# pt push [<id>] [-i,--ignore] [-f,--force] [-m,--milestone <milestone>] [-t,--topic <topic>] [-cc <user> <user>]
+# pt start <topic> | <id>
+# pt propose [new | <branch> | <id>] [-i,--ignore] [-m,--milestone <milestone>] [-t,--topic <topic>] [-cc <user> <user>]
+# pt cleanup [<id>]
+#
+
+__author__ = 'James Moger'
+__version__ = '1.0.5'
+
+import subprocess
+import argparse
+import errno
+import sys
+
+
+def fetch(args):
+ """
+ fetch(args)
+
+ Fetches the specified patchset for the ticket from the specified remote.
+ """
+
+ __resolve_remote(args)
+
+ # fetch the patchset from the remote repository
+
+ if args.patchset is None:
+ # fetch all current ticket patchsets
+ print("Fetching ticket patchsets from the '{}' repository".format(args.remote))
+ if args.quiet:
+ __call(['git', 'fetch', args.remote, '--quiet'])
+ else:
+ __call(['git', 'fetch', args.remote])
+ else:
+ # fetch specific patchset
+ __resolve_patchset(args)
+ print("Fetching ticket {} patchset {} from the '{}' repository".format(args.id, args.patchset, args.remote))
+ patchset_ref = 'refs/tickets/{:02d}/{:d}/{:d}'.format(args.id % 100, args.id, args.patchset)
+ if args.quiet:
+ __call(['git', 'fetch', args.remote, patchset_ref, '--quiet'])
+ else:
+ __call(['git', 'fetch', args.remote, patchset_ref])
+
+ return
+
+
+def checkout(args):
+ """
+ checkout(args)
+
+ Checkout the patchset on a named branch.
+ """
+
+ __resolve_uncommitted_changes_checkout(args)
+ fetch(args)
+
+ # collect local branch names
+ branches = []
+ for branch in __call(['git', 'branch']):
+ if branch[0] == '*':
+ branches.append(branch[1:].strip())
+ else:
+ branches.append(branch.strip())
+
+ if args.patchset is None or args.patchset is 0:
+ branch = 'ticket/{:d}'.format(args.id)
+ illegals = set(branches) & {'ticket'}
+ else:
+ branch = 'patchset/{:d}/{:d}'.format(args.id, args.patchset)
+ illegals = set(branches) & {'patchset', 'patchset/{:d}'.format(args.id)}
+
+ # ensure there are no local branch names that will interfere with branch creation
+ if len(illegals) > 0:
+ print('')
+ print('Sorry, can not complete the checkout for ticket {}.'.format(args.id))
+ print("The following branches are blocking '{}' branch creation:".format(branch))
+ for illegal in illegals:
+ print(' ' + illegal)
+ exit(errno.EINVAL)
+
+ if args.patchset is None or args.patchset is 0:
+ # checkout the current ticket patchset
+ if args.force:
+ __call(['git', 'checkout', '-B', branch, '{}/{}'.format(args.remote, branch)])
+ else:
+ __call(['git', 'checkout', branch])
+ else:
+ # checkout a specific patchset
+ __checkout(args.remote, args.id, args.patchset, branch, args.force)
+
+ return
+
+
+def pull(args):
+ """
+ pull(args)
+
+ Pull (fetch & merge) a ticket patchset into the current branch.
+ """
+
+ __resolve_uncommitted_changes_checkout(args)
+ __resolve_remote(args)
+
+ # reset the checkout before pulling
+ __call(['git', 'reset', '--hard'])
+
+ # pull the patchset from the remote repository
+ if args.patchset is None or args.patchset is 0:
+ print("Pulling ticket {} from the '{}' repository".format(args.id, args.remote))
+ patchset_ref = 'ticket/{:d}'.format(args.id)
+ else:
+ __resolve_patchset(args)
+ print("Pulling ticket {} patchset {} from the '{}' repository".format(args.id, args.patchset, args.remote))
+ patchset_ref = 'refs/tickets/{:02d}/{:d}/{:d}'.format(args.id % 100, args.id, args.patchset)
+
+ if args.squash:
+ __call(['git', 'pull', '--squash', '--no-log', '--no-rebase', args.remote, patchset_ref], echo=True)
+ else:
+ __call(['git', 'pull', '--commit', '--no-ff', '--no-log', '--no-rebase', args.remote, patchset_ref], echo=True)
+
+ return
+
+
+def push(args):
+ """
+ push(args)
+
+ Push your patchset update or a patchset rewrite.
+ """
+
+ if args.id is None:
+ # try to determine ticket and patchset from current branch name
+ for line in __call(['git', 'status', '-b', '-s']):
+ if line[0:2] == '##':
+ branch = line[2:].strip()
+ segments = branch.split('/')
+ if len(segments) >= 2:
+ if segments[0] == 'ticket' or segments[0] == 'patchset':
+ if '...' in segments[1]:
+ args.id = int(segments[1][:segments[1].index('...')])
+ else:
+ args.id = int(segments[1])
+ args.patchset = None
+
+ if args.id is None:
+ print('Please specify a ticket id for the push command.')
+ exit(errno.EINVAL)
+
+ __resolve_uncommitted_changes_push(args)
+ __resolve_remote(args)
+
+ if args.force:
+ # rewrite a patchset for an existing ticket
+ push_ref = 'refs/for/' + str(args.id)
+ else:
+ # fast-forward update to an existing patchset
+ push_ref = 'refs/heads/ticket/{:d}'.format(args.id)
+
+ ref_params = __get_pushref_params(args)
+ ref_spec = 'HEAD:' + push_ref + ref_params
+
+ print("Pushing your patchset to the '{}' repository".format(args.remote))
+ __call(['git', 'push', args.remote, ref_spec], echo=True)
+
+ if args.force and args.patchset is not None and args.patchset is not 0:
+ # if we had to force the push then there is a new patchset
+ # revision on the server so checkout out the new patchset
+ args.patchset = None
+ args.force = False
+ args.quiet = True
+ checkout(args)
+
+ return
+
+
+def start(args):
+ """
+ start(args)
+
+ Start development of a topic on a new branch.
+ """
+
+ # collect local branch names
+ branches = []
+ for branch in __call(['git', 'branch']):
+ if branch[0] == '*':
+ branches.append(branch[1:].strip())
+ else:
+ branches.append(branch.strip())
+
+ branch = 'topic/' + args.topic
+ illegals = set(branches) & {'topic', branch}
+
+ # ensure there are no local branch names that will interfere with branch creation
+ if len(illegals) > 0:
+ print('Sorry, can not complete the creation of the topic branch.')
+ print("The following branches are blocking '{}' branch creation:".format(branch))
+ for illegal in illegals:
+ print(' ' + illegal)
+ exit(errno.EINVAL)
+
+ __call(['git', 'checkout', '-b', branch])
+
+ return
+
+
+def propose(args):
+ """
+ propose_patchset(args)
+
+ Push a patchset to create a new proposal ticket or to attach a proposal patchset to an existing ticket.
+ """
+
+ __resolve_uncommitted_changes_push(args)
+ __resolve_remote(args)
+
+ curr_branch = None
+ push_ref = None
+ if args.target is None:
+ # see if the topic is a ticket id
+ # else default to new
+ for branch in __call(['git', 'branch']):
+ if branch[0] == '*':
+ curr_branch = branch[1:].strip()
+ if curr_branch.startswith('topic/'):
+ topic = curr_branch[6:].strip()
+ try:
+ int(topic)
+ push_ref = topic
+ except ValueError:
+ pass
+ if push_ref is None:
+ push_ref = 'new'
+ else:
+ push_ref = args.target
+
+ try:
+ # check for current patchset and current branch
+ args.id = int(push_ref)
+ args.patchset = __get_current_patchset(args.remote, args.id)
+ if args.patchset > 0:
+ print('You can not propose a patchset for ticket {} because it already has one.'.format(args.id))
+
+ # check current branch for accidental propose instead of push
+ for line in __call(['git', 'status', '-b', '-s']):
+ if line[0:2] == '##':
+ branch = line[2:].strip()
+ segments = branch.split('/')
+ if len(segments) >= 2:
+ if segments[0] == 'ticket':
+ if '...' in segments[1]:
+ args.id = int(segments[1][:segments[1].index('...')])
+ else:
+ args.id = int(segments[1])
+ args.patchset = None
+ print("You are on the '{}' branch, perhaps you meant to push instead?".format(branch))
+ elif segments[0] == 'patchset':
+ args.id = int(segments[1])
+ args.patchset = int(segments[2])
+ print("You are on the '{}' branch, perhaps you meant to push instead?".format(branch))
+ exit(errno.EINVAL)
+ except ValueError:
+ pass
+
+ ref_params = __get_pushref_params(args)
+ ref_spec = 'HEAD:refs/for/{}{}'.format(push_ref, ref_params)
+
+ print("Pushing your proposal to the '{}' repository".format(args.remote))
+ for line in __call(['git', 'push', args.remote, ref_spec, '-q'], echo=True, err=subprocess.STDOUT):
+ fields = line.split(':')
+ if fields[0] == 'remote' and fields[1].strip().startswith('--> #'):
+ # set the upstream branch configuration
+ args.id = int(fields[1].strip()[len('--> #'):])
+ __call(['git', 'fetch', args.remote])
+ __call(['git', 'branch', '--set-upstream-to={}/ticket/{:d}'.format(args.remote, args.id)])
+ break
+
+ return
+
+
+def cleanup(args):
+ """
+ cleanup(args)
+
+ Removes local branches for the ticket.
+ """
+
+ if args.id is None:
+ branches = __call(['git', 'branch', '--list', 'ticket/*'])
+ branches += __call(['git', 'branch', '--list', 'patchset/*'])
+ else:
+ branches = __call(['git', 'branch', '--list', 'ticket/{:d}'.format(args.id)])
+ branches += __call(['git', 'branch', '--list', 'patchset/{:d}/*'.format(args.id)])
+
+ if len(branches) == 0:
+ print("No local branches found for ticket {}, cleanup skipped.".format(args.id))
+ return
+
+ if not args.force:
+ print('Cleanup would remove the following local branches for ticket {}.'.format(args.id))
+ for branch in branches:
+ if branch[0] == '*':
+ print(' ' + branch[1:].strip() + ' (skip)')
+ else:
+ print(' ' + branch)
+ print("To discard these local branches, repeat this command with '--force'.")
+ exit(errno.EINVAL)
+
+ for branch in branches:
+ if branch[0] == '*':
+ print('Skipped {} because it is the current branch.'.format(branch[1:].strip()))
+ continue
+ __call(['git', 'branch', '-D', branch.strip()], echo=True)
+
+ return
+
+
+def __resolve_uncommitted_changes_checkout(args):
+ """
+ __resolve_uncommitted_changes_checkout(args)
+
+ Ensures the current checkout has no uncommitted changes that would be discarded by a checkout or pull.
+ """
+
+ status = __call(['git', 'status', '--porcelain'])
+ for line in status:
+ if not args.force and line[0] != '?':
+ print('Your local changes to the following files would be overwritten by {}:'.format(args.command))
+ print('')
+ for state in status:
+ print(state)
+ print('')
+ print("To discard your local changes, repeat the {} with '--force'.".format(args.command))
+ print('NOTE: forcing a {} will HARD RESET your working directory!'.format(args.command))
+ exit(errno.EINVAL)
+
+
+def __resolve_uncommitted_changes_push(args):
+ """
+ __resolve_uncommitted_changes_push(args)
+
+ Ensures the current checkout has no uncommitted changes that should be part of a propose or push.
+ """
+
+ status = __call(['git', 'status', '--porcelain'])
+ for line in status:
+ if not args.ignore and line[0] != '?':
+ print('You have local changes that have not been committed:')
+ print('')
+ for state in status:
+ print(state)
+ print('')
+ print("To ignore these uncommitted changes, repeat the {} with '--ignore'.".format(args.command))
+ exit(errno.EINVAL)
+
+
+def __resolve_remote(args):
+ """
+ __resolve_remote(args)
+
+ Identifies the git remote to use for fetching and pushing patchsets by parsing .git/config.
+ """
+
+ remotes = __call(['git', 'remote'])
+
+ if len(remotes) == 0:
+ # no remotes defined
+ print("Please define a Git remote")
+ exit(errno.EINVAL)
+ elif len(remotes) == 1:
+ # only one remote, use it
+ args.remote = remotes[0]
+ return
+ else:
+ # multiple remotes, read .git/config
+ output = __call(['git', 'config', '--local', 'patchsets.remote'], fail=False)
+ preferred = output[0] if len(output) > 0 else ''
+
+ if len(preferred) == 0:
+ print("You have multiple remote repositories and you have not configured 'patchsets.remote'.")
+ print("")
+ print("Available remote repositories:")
+ for remote in remotes:
+ print(' ' + remote)
+ print("")
+ print("Please set the remote repository to use for patchsets.")
+ print(" git config --local patchsets.remote <remote>")
+ exit(errno.EINVAL)
+ else:
+ try:
+ remotes.index(preferred)
+ except ValueError:
+ print("The '{}' repository specified in 'patchsets.remote' is not configured!".format(preferred))
+ print("")
+ print("Available remotes:")
+ for remote in remotes:
+ print(' ' + remote)
+ print("")
+ print("Please set the remote repository to use for patchsets.")
+ print(" git config --local patchsets.remote <remote>")
+ exit(errno.EINVAL)
+
+ args.remote = preferred
+ return
+
+
+def __resolve_patchset(args):
+ """
+ __resolve_patchset(args)
+
+ Resolves the current patchset or validates the the specified patchset exists.
+ """
+ if args.patchset is None:
+ # resolve current patchset
+ args.patchset = __get_current_patchset(args.remote, args.id)
+
+ if args.patchset == 0:
+ # there are no patchsets for the ticket or the ticket does not exist
+ print("There are no patchsets for ticket {} in the '{}' repository".format(args.id, args.remote))
+ exit(errno.EINVAL)
+ else:
+ # validate specified patchset
+ args.patchset = __validate_patchset(args.remote, args.id, args.patchset)
+
+ if args.patchset == 0:
+ # there are no patchsets for the ticket or the ticket does not exist
+ print("Patchset {} for ticket {} can not be found in the '{}' repository".format(args.patchset, args.id, args.remote))
+ exit(errno.EINVAL)
+
+ return
+
+
+def __validate_patchset(remote, ticket, patchset):
+ """
+ __validate_patchset(remote, ticket, patchset)
+
+ Validates that the specified ticket patchset exists.
+ """
+
+ nps = 0
+ patchset_ref = 'refs/tickets/{:02d}/{:d}/{:d}'.format(ticket % 100, ticket, patchset)
+ for line in __call(['git', 'ls-remote', remote, patchset_ref]):
+ ps = int(line.split('/')[4])
+ if ps > nps:
+ nps = ps
+
+ if nps == patchset:
+ return patchset
+ return 0
+
+
+def __get_current_patchset(remote, ticket):
+ """
+ __get_current_patchset(remote, ticket)
+
+ Determines the most recent patchset for the ticket by listing the remote patchset refs
+ for the ticket and parsing the patchset numbers from the resulting set.
+ """
+
+ nps = 0
+ patchset_refs = 'refs/tickets/{:02d}/{:d}/*'.format(ticket % 100, ticket)
+ for line in __call(['git', 'ls-remote', remote, patchset_refs]):
+ ps = int(line.split('/')[4])
+ if ps > nps:
+ nps = ps
+
+ return nps
+
+
+def __checkout(remote, ticket, patchset, branch, force=False):
+ """
+ __checkout(remote, ticket, patchset, branch)
+ __checkout(remote, ticket, patchset, branch, force)
+
+ Checkout the patchset on a detached head or on a named branch.
+ """
+
+ has_branch = False
+ on_branch = False
+
+ if branch is None or len(branch) == 0:
+ # checkout the patchset on a detached head
+ print('Checking out ticket {} patchset {} on a detached HEAD'.format(ticket, patchset))
+ __call(['git', 'checkout', 'FETCH_HEAD'], echo=True)
+ return
+ else:
+ # checkout on named branch
+
+ # determine if we are already on the target branch
+ for line in __call(['git', 'branch', '--list', branch]):
+ has_branch = True
+ if line[0] == '*':
+ # current branch (* name)
+ on_branch = True
+
+ if not has_branch:
+ if force:
+ # force the checkout the patchset to the new named branch
+ # used when there are local changes to discard
+ print("Forcing checkout of ticket {} patchset {} on named branch '{}'".format(ticket, patchset, branch))
+ __call(['git', 'checkout', '-b', branch, 'FETCH_HEAD', '--force'], echo=True)
+ else:
+ # checkout the patchset to the new named branch
+ __call(['git', 'checkout', '-b', branch, 'FETCH_HEAD'], echo=True)
+ return
+
+ if not on_branch:
+ # switch to existing local branch
+ __call(['git', 'checkout', branch], echo=True)
+
+ #
+ # now we are on the local branch for the patchset
+ #
+
+ if force:
+ # reset HEAD to FETCH_HEAD, this drops any local changes
+ print("Forcing checkout of ticket {} patchset {} on named branch '{}'".format(ticket, patchset, branch))
+ __call(['git', 'reset', '--hard', 'FETCH_HEAD'], echo=True)
+ return
+ else:
+ # try to merge the existing ref with the FETCH_HEAD
+ merge = __call(['git', 'merge', '--ff-only', branch, 'FETCH_HEAD'], echo=True, fail=False)
+ if len(merge) is 1:
+ up_to_date = merge[0].lower().index('up-to-date') > 0
+ if up_to_date:
+ return
+ elif len(merge) is 0:
+ print('')
+ print("Your '{}' branch has diverged from patchset {} on the '{}' repository.".format(branch, patchset, remote))
+ print('')
+ print("To discard your local changes, repeat the checkout with '--force'.")
+ print('NOTE: forcing a checkout will HARD RESET your working directory!')
+ exit(errno.EINVAL)
+ return
+
+
+def __get_pushref_params(args):
+ """
+ __get_pushref_params(args)
+
+ Returns the push ref parameters for ticket field assignments.
+ """
+
+ params = []
+
+ if args.milestone is not None:
+ params.append('m=' + args.milestone)
+
+ if args.topic is not None:
+ params.append('t=' + args.topic)
+ else:
+ for branch in __call(['git', 'branch']):
+ if branch[0] == '*':
+ b = branch[1:].strip()
+ if b.startswith('topic/'):
+ topic = b[len('topic/'):]
+ try:
+ # ignore ticket id topics
+ int(topic)
+ except:
+ # topic is a string
+ params.append('t=' + topic)
+
+ if args.responsible is not None:
+ params.append('r=' + args.responsible)
+
+ if args.cc is not None:
+ for cc in args.cc:
+ params.append('cc=' + cc)
+
+ if len(params) > 0:
+ return '%' + ','.join(params)
+
+ return ''
+
+
+def __call(cmd_args, echo=False, fail=True, err=None):
+ """
+ __call(cmd_args)
+
+ Executes the specified command as a subprocess. The output is parsed and returned as a list
+ of strings. If the process returns a non-zero exit code, the script terminates with that
+ exit code. Std err of the subprocess is passed-through to the std err of the parent process.
+ """
+
+ p = subprocess.Popen(cmd_args, stdout=subprocess.PIPE, stderr=err, universal_newlines=True)
+ lines = []
+ for line in iter(p.stdout.readline, b''):
+ line_str = str(line).strip()
+ if len(line_str) is 0:
+ break
+ lines.append(line_str)
+ if echo:
+ print(line_str)
+ p.wait()
+ if fail and p.returncode is not 0:
+ exit(p.returncode)
+
+ return lines
+
+#
+# define the acceptable arguments and their usage/descriptions
+#
+
+# force argument
+force_arg = argparse.ArgumentParser(add_help=False)
+force_arg.add_argument('-f', '--force', default=False, help='force the command to complete', action='store_true')
+
+# quiet argument
+quiet_arg = argparse.ArgumentParser(add_help=False)
+quiet_arg.add_argument('-q', '--quiet', default=False, help='suppress git stderr output', action='store_true')
+
+# ticket & patchset arguments
+ticket_args = argparse.ArgumentParser(add_help=False)
+ticket_args.add_argument('id', help='the ticket id', type=int)
+ticket_args.add_argument('-p', '--patchset', help='the patchset number', type=int)
+
+# push refspec arguments
+push_args = argparse.ArgumentParser(add_help=False)
+push_args.add_argument('-i', '--ignore', default=False, help='ignore uncommitted changes', action='store_true')
+push_args.add_argument('-m', '--milestone', help='set the milestone')
+push_args.add_argument('-r', '--responsible', help='set the responsible user')
+push_args.add_argument('-t', '--topic', help='set the topic')
+push_args.add_argument('-cc', nargs='+', help='specify accounts to add to the watch list')
+
+# the commands
+parser = argparse.ArgumentParser(description='a Patchset Tool for Gitblit Tickets')
+parser.add_argument('--version', action='version', version='%(prog)s {}'.format(__version__))
+commands = parser.add_subparsers(dest='command', title='commands')
+
+fetch_parser = commands.add_parser('fetch', help='fetch a patchset', parents=[ticket_args, quiet_arg])
+fetch_parser.set_defaults(func=fetch)
+
+checkout_parser = commands.add_parser('checkout', aliases=['co'],
+ help='fetch & checkout a patchset to a branch',
+ parents=[ticket_args, force_arg, quiet_arg])
+checkout_parser.set_defaults(func=checkout)
+
+pull_parser = commands.add_parser('pull',
+ help='fetch & merge a patchset into the current branch',
+ parents=[ticket_args, force_arg])
+pull_parser.add_argument('-s', '--squash',
+ help='squash the pulled patchset into your working directory',
+ default=False,
+ action='store_true')
+pull_parser.set_defaults(func=pull)
+
+push_parser = commands.add_parser('push', aliases=['up'],
+ help='upload your patchset changes',
+ parents=[push_args, force_arg])
+push_parser.add_argument('id', help='the ticket id', nargs='?', type=int)
+push_parser.set_defaults(func=push)
+
+propose_parser = commands.add_parser('propose', help='propose a new ticket or the first patchset', parents=[push_args])
+propose_parser.add_argument('target', help="the ticket id, 'new', or the integration branch", nargs='?')
+propose_parser.set_defaults(func=propose)
+
+cleanup_parser = commands.add_parser('cleanup', aliases=['rm'],
+ help='remove local ticket branches',
+ parents=[force_arg])
+cleanup_parser.add_argument('id', help='the ticket id', nargs='?', type=int)
+cleanup_parser.set_defaults(func=cleanup)
+
+start_parser = commands.add_parser('start', help='start a new branch for the topic or ticket')
+start_parser.add_argument('topic', help="the topic or ticket id")
+start_parser.set_defaults(func=start)
+
+if len(sys.argv) < 2:
+ parser.parse_args(['--help'])
+else:
+ # parse the command-line arguments
+ script_args = parser.parse_args()
+
+ # exec the specified command
+ script_args.func(script_args)
diff --git a/src/main/java/pt.txt b/src/main/java/pt.txt
new file mode 100644
index 00000000..34703f1a
--- /dev/null
+++ b/src/main/java/pt.txt
@@ -0,0 +1,49 @@
+Barnum, a Patchset Tool (pt)
+
+This Git wrapper script is designed to reduce the ceremony of working with Gitblit patchsets.
+
+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.
+
+
+Linux
+
+1. This script should work out-of-the-box, assuming you have Python 3 and Git.
+2. Put the pt script in a directory on your PATH
+
+Mac OS X
+
+1. Download and install Python 3, if you have not (http://www.python.org)
+2. Put the pt script in a directory on your PATH
+
+Windows
+
+1. Download and install Python 3, if you have not (http://www.python.org)
+2. Download and install Git for Windows, if you have not (http://git-scm.com)
+3. Put the pt.cmd and pt.py file together in a directory on your PATH
+
+
+Usage
+
+ pt fetch <id> [-p,--patchset <n>]
+ pt checkout <id> [-p,--patchset <n>] [-f,--force]
+ pt push [<id>] [-i,--ignore] [-f,--force] [-t,--topic <topic>]
+ [-m,--milestone <milestone>] [-cc <user> <user>]
+ pt pull <id>
+ pt start <topic> | <id>
+ pt propose [new | <branch> | <id>] [-i,--ignore] [-t,--topic <topic>]
+ [-m,--milestone <milestone>] [-cc <user> <user>]
+ pt cleanup [<id>]
+
+ \ No newline at end of file
diff --git a/src/main/resources/barnum_32x32.png b/src/main/resources/barnum_32x32.png
new file mode 100644
index 00000000..e364909d
--- /dev/null
+++ b/src/main/resources/barnum_32x32.png
Binary files differ
diff --git a/src/main/resources/gitblit.css b/src/main/resources/gitblit.css
index cd5c57b0..9c763783 100644
--- a/src/main/resources/gitblit.css
+++ b/src/main/resources/gitblit.css
@@ -38,11 +38,53 @@ a.bugtraq {
font-weight: bold;
}
+.label a.bugtraq {
+ font-weight: normal;
+ color: white;
+}
+
+.lwbadge {
+ color: #888;
+ font-size: 11px;
+ background-color: #e8e8e8;
+ padding: 1px 7px 2px;
+ -webkit-border-radius: 9px;
+ -moz-border-radius: 9px;
+ border-radius: 9px;
+ line-height: 14px;
+ white-space: nowrap;
+ vertical-align: baseline;
+}
+
[class^="icon-"], [class*=" icon-"] i {
/* override for a links that look like bootstrap buttons */
vertical-align: text-bottom;
}
+.pagination-small, .pagination-small ul {
+ margin: 0px !important;
+}
+
+.pagination-small ul {
+ border-right: 1px solid #ddd;
+}
+
+.pagination-small ul > li > a,
+.pagination-small ul > li > span {
+ padding: 2px 8px;
+ font-size: 13px;
+ line-height: 22px;
+ border: 1px solid #ddd;
+ border-right: 0px;
+ border-radius: 0px !important;
+ float: left;
+}
+
+.btn.disabled em, .pagination-small ul > li > span em {
+ font-style: normal;
+ color: #444;
+}
+
hr {
margin-top: 10px;
margin-bottom: 10px;
@@ -137,7 +179,8 @@ navbar div>ul .menu-dropdown li a:hover,.nav .menu-dropdown li a:hover,.navbar d
color: #ffffff !important;
}
-.nav-pills > .active > a, .nav-pills > .active > a:hover {
+.nav-pills > .active > a, .nav-pills > .active > a:hover,
+.nav-list > .active > a, .nav-list > .active > a:hover {
color: #fff;
background-color: #002060;
}
@@ -520,6 +563,24 @@ th {
text-align: left;
}
+table.tickets {
+ border-bottom: 1px solid #ccc;
+}
+
+table.tickets td.indicators {
+ width: 75px;
+ text-align: right;
+ padding-right: 5px;
+ color: #888;
+}
+
+.ticketLabel,
+table.tickets .label {
+ color: white;
+ font-weight: normal;
+ margin: 0px 2px;
+}
+
div.featureWelcome {
padding: 15px;
background-color: #fbfbfb;
@@ -532,6 +593,30 @@ div.featureWelcome div.icon {
font-size: 144px;
}
+li.dynamicQuery {
+ padding: 3px 0px;
+ margin: 1px 0px;
+ border-radius: 4px;
+}
+
+li.dynamicQuery i {
+ color: rgba(255, 255, 255, 0.5);
+ padding-right: 5px;
+}
+
+li.dynamicQuery a.active {
+ color: white;
+}
+
+div.milestoneOverview {
+ color:#888;
+ border: 1px solid #ddd;
+ padding: 2px 5px;
+ text-align: center;
+ font-size: 11px;
+ background-color: #fbfbfb;
+}
+
div.sourceview {
overflow: hidden;
}
@@ -619,6 +704,7 @@ pre.prettyprint ol {
border: 1px solid #ccc;
color: #ccc;
font-weight:bold;
+ display: inline-block;
}
.diffstat-inline {
@@ -650,7 +736,207 @@ pre.prettyprint ol {
.diffstat-delete {
color: #B9583B;
}
-
+.patch-group {
+ margin-bottom: 0px;
+ border: 1px solid #ccc;
+ background-color: #fbfbfb;
+}
+
+.patch-group .accordion-inner {
+ padding: 0px;
+}
+
+.ticket-meta-top {
+ padding: 0px 10px 10px 10px;
+}
+
+.ticket-meta-middle {
+ border: 1px solid #ccc;
+ padding: 10px;
+ background-color: #fbfbfb;
+}
+
+.ticket-meta-bottom {
+ border: 1px solid #ccc;
+ border-top: 0px;
+ padding: 10px;
+}
+
+.ticket-title {
+ font-size: 20px;
+}
+
+.ticket-number {
+ color: #ccc;
+ font-size: 20px;
+ font-weight: normal;
+}
+
+.ticket-list-icon {
+ padding: 8px 0px 8px 8px !important;
+ width: 24px;
+ font-size: 24px;
+ vertical-align: middle !important;
+ color: #888;
+}
+
+td.ticket-list-state {
+ vertical-align: middle;
+}
+
+.ticket-list-details {
+ font-size: 11px;
+ color: #888;
+}
+
+div.ticket-text {
+ max-width: 600px;
+}
+
+.ticket-text-editor {
+ height:7em;
+ border:0px;
+ border-radius: 0px;
+ border-top:1px solid #ccc;
+ margin-bottom:0px;
+ padding:4px;
+ background-color:#ffffff;
+ box-shadow: none;
+}
+
+.indicator-large-dark {
+ font-size: 20px;
+ color: #888;
+}
+
+.indicator-large-light {
+ font-size: 20px;
+ color: #bbb;
+}
+
+.indicator-huge-light {
+ font-size: 48px;
+ color: #bbb;
+}
+
+.attribution-emphasize {
+ font-weight: bold;
+}
+
+.attribution-text {
+ color: #888;
+}
+
+.attribution-border {
+}
+
+.attribution-header {
+ background-color: #fbfbfb;
+ padding: 8px;
+ border: 1px solid #ccc;
+}
+
+.attribution-header-pullright {
+ float: right;
+ text-align: right;
+ padding-right: 1px;
+}
+
+.attribution-patch-pullright {
+ float: right;
+ text-align: right;
+ margin: 5px 10px;
+}
+
+.attribution-date {
+ color: #999;
+ font-size: smaller;
+}
+
+.attribution-link {
+ color: #999;
+ padding-left: 5px;
+}
+
+.attribution-pullright {
+ float: right;
+ text-align: right;
+ padding-right: 8px;
+}
+
+.attribution-triangle {
+ position: absolute;
+ margin-left: -23px;
+ margin-top: 11px;
+ height: 0px;
+ width: 0px;
+ border-image: none;
+ border: 10px solid transparent;
+ border-right: 13px solid #ddd;
+}
+
+.attribution-comment {
+ padding: 10px 10px 0px 10px;
+ /*border: 1px solid #ccc;
+ border-top: 0px;*/
+}
+
+.ticket-simple-event {
+ padding: 5px 0px;
+}
+
+.status-display {
+ text-align: center;
+ font-weight: bold;
+}
+
+.status-change {
+ font-size: 1.0em;
+ text-shadow: none;
+ padding: 5px 10px !important;
+ font-weight: bold;
+ display: inline-block;
+ text-align: center;
+ width: 50px;
+ margin-right: 5px !important;
+}
+
+.submit-info {
+ margin-bottom: 0px;
+ border-radius: 0px;
+}
+
+.merge-panel {
+ padding: 5px 7px;
+ background-color: #fbfbfb;
+ color: #444
+}
+
+.merge-panel p.step {
+ margin: 10px 0px 5px;
+}
+
+.gitcommand {
+ margin-top: 5px;
+ border: 1px solid #ccc;
+ background-color: #333 !important;
+ color: #ccc;
+ border-radius: 3px;
+ padding: 5px;
+ margin-bottom: 5px;
+ text-shadow: none;
+}
+
+a.commit {
+ border: 1px solid #ccc;
+ border-radius: 3px;
+ background-color: #fbfbfb;
+ padding: 2px 4px;
+ line-heihgt:99%;
+ font-size: 11px;
+ text-transform: lowercase;
+}
+
h1 small, h2 small, h3 small, h4 small, h5 small, h6 small {
color: #888;
}
@@ -727,6 +1013,12 @@ span.empty {
color: #008000;
}
+span.highlight {
+ background-color: rgb(255, 255, 100);
+ color: black;
+ padding: 0px 2px;
+}
+
span.link {
color: #888;
}
@@ -775,11 +1067,17 @@ img.overview {
img.gravatar {
background-color: #ffffff;
- border: 1px solid #ddd;
+ /*border: 1px solid #ddd;*/
border-radius: 5px;
padding: 2px;
}
+img.gravatar-round {
+ background-color: #ffffff;
+ border: 1px solid #ccc;
+ border-radius: 100%;
+}
+
img.navbarGravatar {
border: 1px solid #fff;
}
@@ -1157,7 +1455,7 @@ div.references {
text-align: right;
}
-table.plain, table.summary {
+table.plain, table.summary, table.ticket {
width: 0 !important;
border: 0;
}
@@ -1168,11 +1466,16 @@ table.plain th, table.plain td, table.summary th, table.summary td {
border: 0;
}
+table.ticket th, table.ticket td {
+ padding: 1px 3px;
+ border: 0;
+}
+
table.summary {
margin: 0px;
}
-table.summary th {
+table.summary th, table.ticket th {
color: #999;
padding-right: 10px;
text-align: right;
@@ -1662,4 +1965,105 @@ div.markdown table.text th, div.markdown table.text td {
vertical-align: top;
border-top: 1px solid #ccc;
padding:5px;
+}
+.resolution {
+ text-transform: uppercase;
+ font-weight: bold !important;
+ font-size: 11px;
+}
+.resolution-success, .resolution-success a {
+ color: #14892c !important;
+}
+.resolution-success a:hover {
+ color: white !important;
+}
+.resolution-error, .resolution-error a {
+ color: #d04437 !important;
+}
+.resolution-error a:hover {
+ color: white !important;
+}
+.resolution-complete, .resolution-complete a {
+ color: #4a6785 !important
+}
+.resolution-complete a:hover {
+ color: white !important;
+}
+.resolution-current, .resolution-current a {
+ color: #594300 !important;
+}
+.resolution-current, .resolution-current a:hover {
+ color: white;
+}
+
+/*! AUI Lozenge */
+.aui-lozenge {
+ background: #ccc;
+ border: 1px solid #ccc;
+ border-radius: 3px;
+ color: #333;
+ display: inline-block;
+ font-size: 11px;
+ font-weight: bold;
+ line-height: 99%; /* cross-browser compromise to make the line-height match the font-size */
+ margin: 0;
+ padding: 2px 5px;
+ text-align: center;
+ text-decoration: none;
+ text-transform: uppercase;
+}
+.aui-lozenge.aui-lozenge-subtle {
+ background-color: #fff;
+ border-color: #ccc;
+ color: #333;
+}
+.aui-lozenge-success {
+ background-color: #14892c;
+ border-color: #14892c;
+ color: #fff;
+}
+.aui-lozenge-success.aui-lozenge-subtle {
+ background-color: #fff;
+ border-color: #b2d8b9;
+ color: #14892c;
+}
+.aui-lozenge-error {
+ background-color: #d04437;
+ border-color: #d04437;
+ color: #fff;
+}
+.aui-lozenge-error.aui-lozenge-subtle {
+ background-color: #fff;
+ border-color: #f8d3d1;
+ color: #d04437;
+}
+.aui-lozenge-current {
+ background-color: #ffd351;
+ border-color: #ffd351;
+ color: #594300;
+}
+.aui-lozenge-current.aui-lozenge-subtle {
+ background-color: #fff;
+ border-color: #ffe28c;
+ color: #594300;
+}
+.aui-lozenge-complete {
+ background-color: #4a6785;
+ border-color: #4a6785;
+ color: #fff;
+}
+.aui-lozenge-complete.aui-lozenge-subtle {
+ background-color: #fff;
+ border-color: #e4e8ed;
+ color: #4a6785;
+}
+.aui-lozenge-moved {
+ background-color: #815b3a;
+ border-color: #815b3a;
+ color: #fff;
+}
+.aui-lozenge-moved.aui-lozenge-subtle {
+ background-color: #fff;
+ border-color: #ece7e2;
+ color: #815b3a;
} \ No newline at end of file