diff options
Diffstat (limited to 'src/main/java/com/gitblit/tickets/FileTicketService.java')
-rw-r--r-- | src/main/java/com/gitblit/tickets/FileTicketService.java | 467 |
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(); + } +} |