| /* |
| * 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<String, AtomicLong> lastAssignedId; |
| |
| @Inject |
| public FileTicketService( |
| IRuntimeManager runtimeManager, |
| IPluginManager pluginManager, |
| INotificationManager notificationManager, |
| IUserManager userManager, |
| IRepositoryManager repositoryManager) { |
| |
| super(runtimeManager, |
| pluginManager, |
| notificationManager, |
| userManager, |
| repositoryManager); |
| |
| lastAssignedId = new ConcurrentHashMap<String, AtomicLong>(); |
| } |
| |
| @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<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. |
| * |
| * @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<Long> 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<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>(); |
| 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<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(); |
| } |
| } |
| |
| /** |
| * 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 |
| * @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); |
| 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(); |
| } |
| } |