summaryrefslogtreecommitdiffstats
path: root/src/main/java/com/gitblit/tickets/FileTicketService.java
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/java/com/gitblit/tickets/FileTicketService.java')
-rw-r--r--src/main/java/com/gitblit/tickets/FileTicketService.java467
1 files changed, 467 insertions, 0 deletions
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();
+ }
+}