From 685d8311066c673a864fd5645027dd0eecb4550b Mon Sep 17 00:00:00 2001
From: James Moger <james.moger@gitblit.com>
Date: Fri, 02 May 2014 15:44:08 -0400
Subject: [PATCH] Merged #19 "Implement Ticket service migration tool"

---
 src/main/distrib/linux/migrate-tickets.sh                  |   21 ++
 src/main/java/com/gitblit/MigrateTickets.java              |  256 +++++++++++++++++++++++++
 src/main/java/com/gitblit/tickets/FileTicketService.java   |   68 +++++-
 src/test/java/com/gitblit/tests/TicketServiceTest.java     |    5 
 src/main/java/com/gitblit/tickets/ITicketService.java      |   44 ++++
 src/main/distrib/win/migrate-tickets.cmd                   |   21 ++
 src/main/java/com/gitblit/tickets/BranchTicketService.java |   67 +++++-
 src/main/distrib/linux/reindex-tickets.sh                  |    2 
 src/main/java/com/gitblit/tickets/RedisTicketService.java  |   70 ++++++
 src/main/java/com/gitblit/tickets/NullTicketService.java   |   11 +
 src/site/tickets_replication.mkd                           |   24 ++
 11 files changed, 559 insertions(+), 30 deletions(-)

diff --git a/src/main/distrib/linux/migrate-tickets.sh b/src/main/distrib/linux/migrate-tickets.sh
new file mode 100644
index 0000000..f521528
--- /dev/null
+++ b/src/main/distrib/linux/migrate-tickets.sh
@@ -0,0 +1,21 @@
+#!/bin/bash
+# --------------------------------------------------------------------------
+# This is for migrating Tickets from one service to another.
+#
+# usage:
+#
+#     migrate-tickets.sh <outputservice> <baseFolder>
+#
+# --------------------------------------------------------------------------
+
+if [[ -z $1 || -z $2 ]]; then
+    echo "Please specify the output ticket service and your baseFolder!";
+    echo "";
+    echo "usage:";
+    echo "    migrate-tickets <outputservice> <baseFolder>";
+    echo "";
+    exit 1;
+fi
+
+java -cp gitblit.jar:./ext/* com.gitblit.MigrateTickets $1 --baseFolder $2
+
diff --git a/src/main/distrib/linux/reindex-tickets.sh b/src/main/distrib/linux/reindex-tickets.sh
index 5a4fc34..8261b81 100644
--- a/src/main/distrib/linux/reindex-tickets.sh
+++ b/src/main/distrib/linux/reindex-tickets.sh
@@ -11,7 +11,7 @@
 #
 # --------------------------------------------------------------------------
 
-if [ -z $1 ]; then
+if [[ -z $1 ]]; then
     echo "Please specify your baseFolder!";
     echo "";
     echo "usage:";
diff --git a/src/main/distrib/win/migrate-tickets.cmd b/src/main/distrib/win/migrate-tickets.cmd
new file mode 100644
index 0000000..5a26c8e
--- /dev/null
+++ b/src/main/distrib/win/migrate-tickets.cmd
@@ -0,0 +1,21 @@
+@REM --------------------------------------------------------------------------
+@REM This is for migrating Tickets from one service to another.
+@REM
+@REM usage:
+@REM     migrate-tickets <outputservice> <baseFolder>
+@REM
+@REM --------------------------------------------------------------------------
+@if [%1]==[] goto help
+
+@if [%2]==[] goto help
+
+@java -cp gitblit.jar;"%CD%\ext\*" com.gitblit.MigrateTickets %1 --baseFolder %2
+@goto end
+
+:help
+@echo "Please specify the output ticket service and your baseFolder!"
+@echo
+@echo "    migrate-tickets com.gitblit.tickets.RedisTicketService c:/gitblit-data"
+@echo
+
+:end
\ No newline at end of file
diff --git a/src/main/java/com/gitblit/MigrateTickets.java b/src/main/java/com/gitblit/MigrateTickets.java
new file mode 100644
index 0000000..b6d7237
--- /dev/null
+++ b/src/main/java/com/gitblit/MigrateTickets.java
@@ -0,0 +1,256 @@
+/*
+ * 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 java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.CmdLineException;
+import org.kohsuke.args4j.CmdLineParser;
+import org.kohsuke.args4j.Option;
+
+import com.gitblit.manager.IRepositoryManager;
+import com.gitblit.manager.IRuntimeManager;
+import com.gitblit.manager.RepositoryManager;
+import com.gitblit.manager.RuntimeManager;
+import com.gitblit.models.RepositoryModel;
+import com.gitblit.models.TicketModel;
+import com.gitblit.models.TicketModel.Change;
+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 move all tickets from one ticket service to another.
+ *
+ * @author James Moger
+ *
+ */
+public class MigrateTickets {
+
+	public static void main(String... args) {
+		MigrateTickets migrate = new MigrateTickets();
+
+		// 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();
+		CmdLineParser parser = new CmdLineParser(params);
+		try {
+			parser.parseArgument(filtered);
+			if (params.help) {
+				migrate.usage(parser, null);
+				return;
+			}
+		} catch (CmdLineException t) {
+			migrate.usage(parser, 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);
+			}
+		}
+
+		// migrate tickets
+		migrate.migrate(new File(Params.baseFolder), settings, params.outputServiceName);
+		System.exit(0);
+	}
+
+	/**
+	 * Display the command line usage of MigrateTickets.
+	 *
+	 * @param parser
+	 * @param t
+	 */
+	protected final void usage(CmdLineParser parser, CmdLineException 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 (parser != null) {
+			parser.printUsage(System.out);
+			System.out
+					.println("\nExample:\n  java -gitblit.jar com.gitblit.MigrateTickets com.gitblit.tickets.RedisTicketService --baseFolder c:\\gitblit-data");
+		}
+		System.exit(0);
+	}
+
+	/**
+	 * Migrate all tickets
+	 *
+	 * @param baseFolder
+	 * @param settings
+	 * @param outputServiceName
+	 */
+	protected void migrate(File baseFolder, IStoredSettings settings, String outputServiceName) {
+		// 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);
+		settings.overrideSetting(ITicketService.SETTING_UPDATE_DIFFSTATS, false);
+
+		IRuntimeManager runtimeManager = new RuntimeManager(settings, baseFolder).start();
+		IRepositoryManager repositoryManager = new RepositoryManager(runtimeManager, null).start();
+
+		String inputServiceName = settings.getString(Keys.tickets.service, BranchTicketService.class.getSimpleName());
+		if (StringUtils.isEmpty(inputServiceName)) {
+			System.err.println(MessageFormat.format("Please define a ticket service in \"{0}\"", Keys.tickets.service));
+			System.exit(1);
+		}
+
+		ITicketService inputService = null;
+		ITicketService outputService = null;
+		try {
+			inputService = getService(inputServiceName, runtimeManager, repositoryManager);
+			outputService = getService(outputServiceName, runtimeManager, repositoryManager);
+		} catch (Exception e) {
+			e.printStackTrace();
+			System.exit(1);
+		}
+
+		if (!inputService.isReady()) {
+			System.err.println(String.format("%s INPUT service is not ready, check config.", inputService.getClass().getSimpleName()));
+			System.exit(1);
+		}
+
+		if (!outputService.isReady()) {
+			System.err.println(String.format("%s OUTPUT service is not ready, check config.", outputService.getClass().getSimpleName()));
+			System.exit(1);
+		}
+
+		// migrate tickets
+		long start = System.nanoTime();
+		long totalTickets = 0;
+		long totalChanges = 0;
+		for (RepositoryModel repository : repositoryManager.getRepositoryModels(null)) {
+			Set<Long> ids = inputService.getIds(repository);
+			if (ids == null || ids.isEmpty()) {
+				// nothing to migrate
+				continue;
+			}
+
+			// delete any tickets we may have in the output ticket service
+			outputService.deleteAll(repository);
+
+			for (long id : ids) {
+				List<Change> journal = inputService.getJournal(repository, id);
+				if (journal == null || journal.size() == 0) {
+					continue;
+				}
+				TicketModel ticket = outputService.createTicket(repository, id, journal.get(0));
+				if (ticket == null) {
+					System.err.println(String.format("Failed to migrate %s #%s", repository.name, id));
+					System.exit(1);
+				}
+				totalTickets++;
+				System.out.println(String.format("%s #%s: %s", repository.name, ticket.number, ticket.title));
+				for (int i = 1; i < journal.size(); i++) {
+					TicketModel updated = outputService.updateTicket(repository, ticket.number, journal.get(i));
+					if (updated != null) {
+						System.out.println(String.format("   applied change %d", i));
+						totalChanges++;
+					} else {
+						System.err.println(String.format("Failed to apply change %d:\n%s", i, journal.get(i)));
+						System.exit(1);
+					}
+				}
+			}
+		}
+
+		inputService.stop();
+		outputService.stop();
+
+		repositoryManager.stop();
+		runtimeManager.stop();
+
+		long end = System.nanoTime();
+
+		System.out.println(String.format("Migrated %d tickets composed of %d journal entries in %d seconds",
+				totalTickets, totalTickets + totalChanges, TimeUnit.NANOSECONDS.toSeconds(end - start)));
+	}
+
+	protected ITicketService getService(String serviceName, IRuntimeManager runtimeManager, IRepositoryManager repositoryManager) throws Exception {
+		ITicketService service = null;
+		Class<?> serviceClass = Class.forName(serviceName);
+		if (RedisTicketService.class.isAssignableFrom(serviceClass)) {
+			// Redis ticket service
+			service = new RedisTicketService(runtimeManager, null, null, null, repositoryManager).start();
+		} else if (BranchTicketService.class.isAssignableFrom(serviceClass)) {
+			// Branch ticket service
+			service = new BranchTicketService(runtimeManager, null, null, null, repositoryManager).start();
+		} else if (FileTicketService.class.isAssignableFrom(serviceClass)) {
+			// File ticket service
+			service = new FileTicketService(runtimeManager, null, null, null, repositoryManager).start();
+		} else {
+			System.err.println("Unknown ticket service " + serviceName);
+		}
+		return service;
+	}
+
+	/**
+	 * Parameters.
+	 */
+	public static class Params {
+
+		public static String baseFolder;
+
+		@Option(name = "--help", aliases = { "-h"}, usage = "Show this help")
+		public Boolean help = false;
+
+		private final FileSettings FILESETTINGS = new FileSettings(new File(baseFolder, Constants.PROPERTIES_FILE).getAbsolutePath());
+
+		@Option(name = "--repositoriesFolder", usage = "Git Repositories Folder", metaVar = "PATH")
+		public String repositoriesFolder = FILESETTINGS.getString(Keys.git.repositoriesFolder, "git");
+
+		@Option(name = "--settings", usage = "Path to alternative settings", metaVar = "FILE")
+		public String settingsfile;
+
+		@Argument(index = 0, required = true, metaVar = "OUTPUTSERVICE", usage = "The destination/output ticket service")
+		public String outputServiceName;
+	}
+}
diff --git a/src/main/java/com/gitblit/tickets/BranchTicketService.java b/src/main/java/com/gitblit/tickets/BranchTicketService.java
index 284b1be..8c00055 100644
--- a/src/main/java/com/gitblit/tickets/BranchTicketService.java
+++ b/src/main/java/com/gitblit/tickets/BranchTicketService.java
@@ -378,6 +378,37 @@
 	}
 
 	/**
+	 * Returns the assigned ticket ids.
+	 *
+	 * @return the assigned ticket ids
+	 */
+	@Override
+	public synchronized Set<Long> getIds(RepositoryModel repository) {
+		Repository db = repositoryManager.getRepository(repository.name);
+		try {
+			if (getTicketsBranch(db) == null) {
+				return Collections.emptySet();
+			}
+			Set<Long> ids = new TreeSet<Long>();
+			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);
+				ids.add(ticketId);
+			}
+			return ids;
+		} finally {
+			if (db != null) {
+				db.close();
+			}
+		}
+	}
+
+	/**
 	 * Assigns a new ticket id.
 	 *
 	 * @param repository
@@ -398,16 +429,10 @@
 			}
 			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);
+				Set<Long> ids = getIds(repository);
+				for (long id : ids) {
+					if (id > lastId.get()) {
+						lastId.set(id);
 					}
 				}
 			}
@@ -526,6 +551,28 @@
 	}
 
 	/**
+	 * Retrieves the journal for the ticket.
+	 *
+	 * @param repository
+	 * @param ticketId
+	 * @return a journal, if it exists, otherwise null
+	 */
+	@Override
+	protected List<Change> getJournalImpl(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;
+			}
+			return changes;
+		} finally {
+			db.close();
+		}
+	}
+
+	/**
 	 * Returns the journal for the specified ticket.
 	 *
 	 * @param db
diff --git a/src/main/java/com/gitblit/tickets/FileTicketService.java b/src/main/java/com/gitblit/tickets/FileTicketService.java
index 4386020..b3d8838 100644
--- a/src/main/java/com/gitblit/tickets/FileTicketService.java
+++ b/src/main/java/com/gitblit/tickets/FileTicketService.java
@@ -22,6 +22,8 @@
 import java.util.Collections;
 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;
 
@@ -146,6 +148,31 @@
 		return hasTicket;
 	}
 
+	@Override
+	public synchronized Set<Long> getIds(RepositoryModel repository) {
+		Set<Long> ids = new TreeSet<Long>();
+		Repository db = repositoryManager.getRepository(repository.name);
+		try {
+			// 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);
+				ids.add(ticketId);
+			}
+		} finally {
+			if (db != null) {
+				db.close();
+			}
+		}
+		return ids;
+	}
+
 	/**
 	 * Assigns a new ticket id.
 	 *
@@ -162,18 +189,10 @@
 			}
 			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);
+				Set<Long> ids = getIds(repository);
+				for (long id : ids) {
+					if (id > lastId.get()) {
+						lastId.set(id);
 					}
 				}
 			}
@@ -284,8 +303,7 @@
 	}
 
 	/**
-	 * Retrieves the ticket from the repository by first looking-up the changeId
-	 * associated with the ticketId.
+	 * Retrieves the ticket from the repository.
 	 *
 	 * @param repository
 	 * @param ticketId
@@ -313,6 +331,28 @@
 	}
 
 	/**
+	 * Retrieves the journal for the ticket.
+	 *
+	 * @param repository
+	 * @param ticketId
+	 * @return a journal, if it exists, otherwise null
+	 */
+	@Override
+	protected List<Change> getJournalImpl(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;
+			}
+			return changes;
+		} finally {
+			db.close();
+		}
+	}
+
+	/**
 	 * Returns the journal for the specified ticket.
 	 *
 	 * @param db
diff --git a/src/main/java/com/gitblit/tickets/ITicketService.java b/src/main/java/com/gitblit/tickets/ITicketService.java
index 3261ca9..a6a7a75 100644
--- a/src/main/java/com/gitblit/tickets/ITicketService.java
+++ b/src/main/java/com/gitblit/tickets/ITicketService.java
@@ -65,6 +65,8 @@
  */
 public abstract class ITicketService {
 
+	public static final String SETTING_UPDATE_DIFFSTATS = "migration.updateDiffstats";
+
 	private static final String LABEL = "label";
 
 	private static final String MILESTONE = "milestone";
@@ -106,6 +108,8 @@
 	private final Map<String, List<TicketLabel>> labelsCache;
 
 	private final Map<String, List<TicketMilestone>> milestonesCache;
+
+	private final boolean updateDiffstats;
 
 	private static class TicketKey {
 		final String repository;
@@ -164,6 +168,8 @@
 
 		this.labelsCache = new ConcurrentHashMap<String, List<TicketLabel>>();
 		this.milestonesCache = new ConcurrentHashMap<String, List<TicketMilestone>>();
+
+		this.updateDiffstats = settings.getBoolean(SETTING_UPDATE_DIFFSTATS, true);
 	}
 
 	/**
@@ -762,6 +768,15 @@
 	}
 
 	/**
+	 * Returns the set of assigned ticket ids in the repository.
+	 *
+	 * @param repository
+	 * @return a set of assigned ticket ids in the repository
+	 * @since 1.6.0
+	 */
+	public abstract Set<Long> getIds(RepositoryModel repository);
+
+	/**
 	 * Assigns a new ticket id.
 	 *
 	 * @param repository
@@ -823,7 +838,7 @@
 			ticket = getTicketImpl(repository, ticketId);
 			// if ticket exists
 			if (ticket != null) {
-				if (ticket.hasPatchsets()) {
+				if (ticket.hasPatchsets() && updateDiffstats) {
 					Repository r = repositoryManager.getRepository(repository.name);
 					try {
 						Patchset patchset = ticket.getCurrentPatchset();
@@ -856,6 +871,33 @@
 	 */
 	protected abstract TicketModel getTicketImpl(RepositoryModel repository, long ticketId);
 
+
+	/**
+	 * Returns the journal used to build a ticket.
+	 *
+	 * @param repository
+	 * @param ticketId
+	 * @return the journal for the ticket, if it exists, otherwise null
+	 * @since 1.6.0
+	 */
+	public final List<Change> getJournal(RepositoryModel repository, long ticketId) {
+		if (hasTicket(repository, ticketId)) {
+			List<Change> journal = getJournalImpl(repository, ticketId);
+			return journal;
+		}
+		return null;
+	}
+
+	/**
+	 * Retrieves the ticket journal.
+	 *
+	 * @param repository
+	 * @param ticketId
+	 * @return a ticket, if it exists, otherwise null
+	 * @since 1.6.0
+	 */
+	protected abstract List<Change> getJournalImpl(RepositoryModel repository, long ticketId);
+
 	/**
 	 * Get the ticket url
 	 *
diff --git a/src/main/java/com/gitblit/tickets/NullTicketService.java b/src/main/java/com/gitblit/tickets/NullTicketService.java
index 749d801..d410cdd 100644
--- a/src/main/java/com/gitblit/tickets/NullTicketService.java
+++ b/src/main/java/com/gitblit/tickets/NullTicketService.java
@@ -17,6 +17,7 @@
 
 import java.util.Collections;
 import java.util.List;
+import java.util.Set;
 
 import com.gitblit.manager.INotificationManager;
 import com.gitblit.manager.IPluginManager;
@@ -78,6 +79,11 @@
 	}
 
 	@Override
+	public synchronized Set<Long> getIds(RepositoryModel repository) {
+		return Collections.emptySet();
+	}
+
+	@Override
 	public synchronized long assignNewId(RepositoryModel repository) {
 		return 0L;
 	}
@@ -93,6 +99,11 @@
 	}
 
 	@Override
+	protected List<Change> getJournalImpl(RepositoryModel repository, long ticketId) {
+		return null;
+	}
+
+	@Override
 	public boolean supportsAttachments() {
 		return false;
 	}
diff --git a/src/main/java/com/gitblit/tickets/RedisTicketService.java b/src/main/java/com/gitblit/tickets/RedisTicketService.java
index 2c5b181..d773b0b 100644
--- a/src/main/java/com/gitblit/tickets/RedisTicketService.java
+++ b/src/main/java/com/gitblit/tickets/RedisTicketService.java
@@ -20,6 +20,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Set;
+import java.util.TreeSet;
 
 import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
 
@@ -184,6 +185,30 @@
 		return false;
 	}
 
+	@Override
+	public Set<Long> getIds(RepositoryModel repository) {
+		Set<Long> ids = new TreeSet<Long>();
+		Jedis jedis = pool.getResource();
+		try {// account for migrated tickets
+			Set<String> keys = jedis.keys(key(repository, KeyType.journal, "*"));
+			for (String tkey : keys) {
+				// {repo}:journal:{id}
+				String id = tkey.split(":")[2];
+				long ticketId = Long.parseLong(id);
+				ids.add(ticketId);
+			}
+		} 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 ids;
+	}
+
 	/**
 	 * Assigns a new ticket id.
 	 *
@@ -197,7 +222,14 @@
 			String key = key(repository, KeyType.counter, null);
 			String val = jedis.get(key);
 			if (isNull(val)) {
-				jedis.set(key, "0");
+				long lastId = 0;
+				Set<Long> ids = getIds(repository);
+				for (long id : ids) {
+					if (id > lastId) {
+						lastId = id;
+					}
+				}
+				jedis.set(key, "" + lastId);
 			}
 			long ticketNumber = jedis.incr(key);
 			return ticketNumber;
@@ -273,8 +305,7 @@
 	}
 
 	/**
-	 * Retrieves the ticket from the repository by first looking-up the changeId
-	 * associated with the ticketId.
+	 * Retrieves the ticket from the repository.
 	 *
 	 * @param repository
 	 * @param ticketId
@@ -312,6 +343,39 @@
 	}
 
 	/**
+	 * Retrieves the journal for the ticket.
+	 *
+	 * @param repository
+	 * @param ticketId
+	 * @return a journal, if it exists, otherwise null
+	 */
+	@Override
+	protected List<Change> getJournalImpl(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;
+			}
+			return changes;
+		} catch (JedisException e) {
+			log.error("failed to retrieve journal 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
diff --git a/src/site/tickets_replication.mkd b/src/site/tickets_replication.mkd
index 472c727..a72df27 100644
--- a/src/site/tickets_replication.mkd
+++ b/src/site/tickets_replication.mkd
@@ -133,3 +133,27 @@
     curl --insecure --user admin:admin "https://localhost:8443/rpc?req=reindex_tickets"
     curl --insecure --user admin:admin "https://localhost:8443/rpc?req=reindex_tickets&name=gitblit.git"
 
+#### Migrating Tickets between Ticket Services
+
+##### Gitblit GO
+
+Gitblit GO ships with a script that executes the *com.gitblit.MigrateTickets* tool included in the Gitblit jar file.  This tool will migrate *all* tickets in *all* repositories **AND** must be run when Gitblit is offline.
+
+    migrate-tickets <outputservice> <baseFolder>
+
+For example, this would migrate tickets from the current ticket service configured in `c:\gitblit-data\gitblit.properties` to a Redis ticket service.  The Redis service is configured in the same config file so you must be sure to properly setup all appropriate Redis settings.
+
+    migrate-tickets com.gitblit.tickets.RedisTicketService c:\gitblit-data
+
+##### Gitblit WAR/Express
+
+Gitblit WAR/Express does not ship with anything other than the WAR, but you can still migrate tickets offline with a little extra effort.
+
+*Windows*
+
+    java -cp "C:/path/to/WEB-INF/lib/*" com.gitblit.MigrateTickets <outputservice> --baseFolder <baseFolder>
+
+*Linux/Unix/Mac OSX*
+
+    java -cp /path/to/WEB-INF/lib/* com.gitblit.MigrateTickets <outputservice> --baseFolder <baseFolder>
+
diff --git a/src/test/java/com/gitblit/tests/TicketServiceTest.java b/src/test/java/com/gitblit/tests/TicketServiceTest.java
index d91ce53..1676e34 100644
--- a/src/test/java/com/gitblit/tests/TicketServiceTest.java
+++ b/src/test/java/com/gitblit/tests/TicketServiceTest.java
@@ -95,7 +95,7 @@
 		// query non-existent ticket
 		TicketModel nonExistent = service.getTicket(getRepository(), 0);
 		assertNull(nonExistent);
-		
+
 		// create and insert a ticket
 		Change c1 = newChange("testCreation() " + Long.toHexString(System.currentTimeMillis()));
 		TicketModel ticket = service.createTicket(getRepository(), c1);
@@ -205,6 +205,9 @@
 		assertEquals(1, results.size());
 		assertTrue(results.get(0).title.startsWith("testUpdates"));
 
+		// check the ids
+		assertEquals("[1, 2]", service.getIds(getRepository()).toString());
+
 		// delete all tickets
 		for (TicketModel aTicket : allTickets) {
 			assertTrue(service.deleteTicket(getRepository(), aTicket.number, "D"));

--
Gitblit v1.9.1