/* * 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.Set; import java.util.TreeSet; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; import org.eclipse.jgit.lib.Repository; import com.gitblit.Constants; import com.gitblit.manager.INotificationManager; import com.gitblit.manager.IPluginManager; 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; import com.google.inject.Inject; import com.google.inject.Singleton; /** * 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 * */ @Singleton public class FileTicketService extends ITicketService { private static final String JOURNAL = "journal.json"; private static final String TICKETS_PATH = "tickets/"; private final Map lastAssignedId; @Inject public FileTicketService( IRuntimeManager runtimeManager, IPluginManager pluginManager, INotificationManager notificationManager, IUserManager userManager, IRepositoryManager repositoryManager) { super(runtimeManager, pluginManager, notificationManager, userManager, repositoryManager); lastAssignedId = new ConcurrentHashMap(); } @Override public FileTicketService start() { log.info("{} started", getClass().getSimpleName()); 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; } @Override public synchronized Set getIds(RepositoryModel repository) { Set ids = new TreeSet(); 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 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. * * @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) { Set ids = getIds(repository); for (long id : ids) { if (id > lastId.get()) { lastId.set(id); } } } // 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 getTickets(RepositoryModel repository, TicketFilter filter) { List list = new ArrayList(); Repository db = repositoryManager.getRepository(repository.name); try { // Collect the set of all json files File dir = new File(db.getDirectory(), TICKETS_PATH); List 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 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 findAll(File dir, String filename) { List list = new ArrayList(); File [] files = dir.listFiles(); if (files == null) { return list; } for (File file : files) { if (file.isDirectory()) { list.addAll(findAll(file, filename)); } else if (file.isFile()) { if (file.getName().equalsIgnoreCase(filename)) { list.add(file); } } } return list; } /** * Retrieves the ticket from the repository. * * @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 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(); } } /** * Retrieves the journal for the ticket. * * @param repository * @param ticketId * @return a journal, if it exists, otherwise null */ @Override protected List getJournalImpl(RepositoryModel repository, long ticketId) { Repository db = repositoryManager.getRepository(repository.name); try { List 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 * @param ticketId * @return a list of changes */ private List getJournal(Repository db, long ticketId) { if (ticketId <= 0L) { return new ArrayList(); } String journalPath = toTicketPath(ticketId) + "/" + JOURNAL; File journal = new File(db.getDirectory(), journalPath); if (!journal.exists()) { return new ArrayList(); } 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(); } List 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 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); if (db == null) { // the tickets no longer exist because the db no longer exists return true; } 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(); } }