James Moger
2015-11-22 ed552ba47c02779c270ffd62841d6d1048dade70
commit | author | age
5e3521 1 /*
JM 2  * Copyright 2014 gitblit.com.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *     http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.gitblit.tickets;
17
18 import java.io.File;
19 import java.io.IOException;
20 import java.text.MessageFormat;
21 import java.util.ArrayList;
22 import java.util.Collections;
23 import java.util.List;
24 import java.util.Map;
4d81c9 25 import java.util.Set;
JM 26 import java.util.TreeSet;
5e3521 27 import java.util.concurrent.ConcurrentHashMap;
JM 28 import java.util.concurrent.atomic.AtomicLong;
29
30 import org.eclipse.jgit.lib.Repository;
31
32 import com.gitblit.Constants;
33 import com.gitblit.manager.INotificationManager;
ba5670 34 import com.gitblit.manager.IPluginManager;
5e3521 35 import com.gitblit.manager.IRepositoryManager;
JM 36 import com.gitblit.manager.IRuntimeManager;
37 import com.gitblit.manager.IUserManager;
38 import com.gitblit.models.RepositoryModel;
39 import com.gitblit.models.TicketModel;
40 import com.gitblit.models.TicketModel.Attachment;
41 import com.gitblit.models.TicketModel.Change;
42 import com.gitblit.utils.ArrayUtils;
43 import com.gitblit.utils.FileUtils;
44 import com.gitblit.utils.StringUtils;
c42032 45 import com.google.inject.Inject;
JM 46 import com.google.inject.Singleton;
5e3521 47
JM 48 /**
49  * Implementation of a ticket service based on a directory within the repository.
50  * All tickets are serialized as a list of JSON changes and persisted in a hashed
51  * directory structure, similar to the standard git loose object structure.
52  *
53  * @author James Moger
54  *
55  */
aa1361 56 @Singleton
5e3521 57 public class FileTicketService extends ITicketService {
JM 58
59     private static final String JOURNAL = "journal.json";
60
61     private static final String TICKETS_PATH = "tickets/";
62
63     private final Map<String, AtomicLong> lastAssignedId;
64
aa1361 65     @Inject
5e3521 66     public FileTicketService(
JM 67             IRuntimeManager runtimeManager,
ba5670 68             IPluginManager pluginManager,
5e3521 69             INotificationManager notificationManager,
JM 70             IUserManager userManager,
71             IRepositoryManager repositoryManager) {
72
73         super(runtimeManager,
ba5670 74                 pluginManager,
5e3521 75                 notificationManager,
JM 76                 userManager,
77                 repositoryManager);
78
79         lastAssignedId = new ConcurrentHashMap<String, AtomicLong>();
80     }
81
82     @Override
83     public FileTicketService start() {
c42032 84         log.info("{} started", getClass().getSimpleName());
5e3521 85         return this;
JM 86     }
87
88     @Override
89     protected void resetCachesImpl() {
90         lastAssignedId.clear();
91     }
92
93     @Override
94     protected void resetCachesImpl(RepositoryModel repository) {
95         if (lastAssignedId.containsKey(repository.name)) {
96             lastAssignedId.get(repository.name).set(0);
97         }
98     }
99
100     @Override
101     protected void close() {
102     }
103
104     /**
105      * Returns the ticket path. This follows the same scheme as Git's object
106      * store path where the first two characters of the hash id are the root
107      * folder with the remaining characters as a subfolder within that folder.
108      *
109      * @param ticketId
110      * @return the root path of the ticket content in the ticket directory
111      */
112     private String toTicketPath(long ticketId) {
113         StringBuilder sb = new StringBuilder();
114         sb.append(TICKETS_PATH);
115         long m = ticketId % 100L;
116         if (m < 10) {
117             sb.append('0');
118         }
119         sb.append(m);
120         sb.append('/');
121         sb.append(ticketId);
122         return sb.toString();
123     }
124
125     /**
126      * Returns the path to the attachment for the specified ticket.
127      *
128      * @param ticketId
129      * @param filename
130      * @return the path to the specified attachment
131      */
132     private String toAttachmentPath(long ticketId, String filename) {
133         return toTicketPath(ticketId) + "/attachments/" + filename;
134     }
135
136     /**
137      * Ensures that we have a ticket for this ticket id.
138      *
139      * @param repository
140      * @param ticketId
141      * @return true if the ticket exists
142      */
143     @Override
144     public boolean hasTicket(RepositoryModel repository, long ticketId) {
145         boolean hasTicket = false;
146         Repository db = repositoryManager.getRepository(repository.name);
147         try {
148             String journalPath = toTicketPath(ticketId) + "/" + JOURNAL;
149             hasTicket = new File(db.getDirectory(), journalPath).exists();
150         } finally {
151             db.close();
152         }
153         return hasTicket;
154     }
155
4d81c9 156     @Override
JM 157     public synchronized Set<Long> getIds(RepositoryModel repository) {
158         Set<Long> ids = new TreeSet<Long>();
159         Repository db = repositoryManager.getRepository(repository.name);
160         try {
161             // identify current highest ticket id by scanning the paths in the tip tree
162             File dir = new File(db.getDirectory(), TICKETS_PATH);
163             dir.mkdirs();
164             List<File> journals = findAll(dir, JOURNAL);
165             for (File journal : journals) {
166                 // Reconstruct ticketId from the path
167                 // id/26/326/journal.json
168                 String path = FileUtils.getRelativePath(dir, journal);
169                 String tid = path.split("/")[1];
170                 long ticketId = Long.parseLong(tid);
171                 ids.add(ticketId);
172             }
173         } finally {
174             if (db != null) {
175                 db.close();
176             }
177         }
178         return ids;
179     }
180
5e3521 181     /**
JM 182      * Assigns a new ticket id.
183      *
184      * @param repository
185      * @return a new long id
186      */
187     @Override
188     public synchronized long assignNewId(RepositoryModel repository) {
189         long newId = 0L;
190         Repository db = repositoryManager.getRepository(repository.name);
191         try {
192             if (!lastAssignedId.containsKey(repository.name)) {
193                 lastAssignedId.put(repository.name, new AtomicLong(0));
194             }
195             AtomicLong lastId = lastAssignedId.get(repository.name);
196             if (lastId.get() <= 0) {
4d81c9 197                 Set<Long> ids = getIds(repository);
JM 198                 for (long id : ids) {
199                     if (id > lastId.get()) {
200                         lastId.set(id);
5e3521 201                     }
JM 202                 }
203             }
204
205             // assign the id and touch an empty journal to hold it's place
206             newId = lastId.incrementAndGet();
207             String journalPath = toTicketPath(newId) + "/" + JOURNAL;
208             File journal = new File(db.getDirectory(), journalPath);
209             journal.getParentFile().mkdirs();
210             journal.createNewFile();
211         } catch (IOException e) {
212             log.error("failed to assign ticket id", e);
213             return 0L;
214         } finally {
215             db.close();
216         }
217         return newId;
218     }
219
220     /**
221      * Returns all the tickets in the repository. Querying tickets from the
222      * repository requires deserializing all tickets. This is an  expensive
223      * process and not recommended. Tickets are indexed by Lucene and queries
224      * should be executed against that index.
225      *
226      * @param repository
227      * @param filter
228      *            optional filter to only return matching results
229      * @return a list of tickets
230      */
231     @Override
232     public List<TicketModel> getTickets(RepositoryModel repository, TicketFilter filter) {
233         List<TicketModel> list = new ArrayList<TicketModel>();
234
235         Repository db = repositoryManager.getRepository(repository.name);
236         try {
237             // Collect the set of all json files
238             File dir = new File(db.getDirectory(), TICKETS_PATH);
239             List<File> journals = findAll(dir, JOURNAL);
240
241             // Deserialize each ticket and optionally filter out unwanted tickets
242             for (File journal : journals) {
243                 String json = null;
244                 try {
245                     json = new String(FileUtils.readContent(journal), Constants.ENCODING);
246                 } catch (Exception e) {
247                     log.error(null, e);
248                 }
249                 if (StringUtils.isEmpty(json)) {
250                     // journal was touched but no changes were written
251                     continue;
252                 }
253                 try {
254                     // Reconstruct ticketId from the path
255                     // id/26/326/journal.json
256                     String path = FileUtils.getRelativePath(dir, journal);
257                     String tid = path.split("/")[1];
258                     long ticketId = Long.parseLong(tid);
259                     List<Change> changes = TicketSerializer.deserializeJournal(json);
260                     if (ArrayUtils.isEmpty(changes)) {
261                         log.warn("Empty journal for {}:{}", repository, journal);
262                         continue;
263                     }
264                     TicketModel ticket = TicketModel.buildTicket(changes);
265                     ticket.project = repository.projectPath;
266                     ticket.repository = repository.name;
267                     ticket.number = ticketId;
268
269                     // add the ticket, conditionally, to the list
270                     if (filter == null) {
271                         list.add(ticket);
272                     } else {
273                         if (filter.accept(ticket)) {
274                             list.add(ticket);
275                         }
276                     }
277                 } catch (Exception e) {
278                     log.error("failed to deserialize {}/{}\n{}",
279                             new Object [] { repository, journal, e.getMessage()});
280                     log.error(null, e);
281                 }
282             }
283
284             // sort the tickets by creation
285             Collections.sort(list);
286             return list;
287         } finally {
288             db.close();
289         }
290     }
291
292     private List<File> findAll(File dir, String filename) {
293         List<File> list = new ArrayList<File>();
7f19d3 294         File [] files = dir.listFiles();
JM 295         if (files == null) {
296             return list;
297         }
298         for (File file : files) {
5e3521 299             if (file.isDirectory()) {
JM 300                 list.addAll(findAll(file, filename));
301             } else if (file.isFile()) {
7f19d3 302                 if (file.getName().equalsIgnoreCase(filename)) {
5e3521 303                     list.add(file);
JM 304                 }
305             }
306         }
307         return list;
308     }
309
310     /**
4d81c9 311      * Retrieves the ticket from the repository.
5e3521 312      *
JM 313      * @param repository
314      * @param ticketId
315      * @return a ticket, if it exists, otherwise null
316      */
317     @Override
318     protected TicketModel getTicketImpl(RepositoryModel repository, long ticketId) {
319         Repository db = repositoryManager.getRepository(repository.name);
320         try {
321             List<Change> changes = getJournal(db, ticketId);
322             if (ArrayUtils.isEmpty(changes)) {
323                 log.warn("Empty journal for {}:{}", repository, ticketId);
324                 return null;
325             }
326             TicketModel ticket = TicketModel.buildTicket(changes);
327             if (ticket != null) {
328                 ticket.project = repository.projectPath;
329                 ticket.repository = repository.name;
330                 ticket.number = ticketId;
331             }
332             return ticket;
333         } finally {
334             db.close();
335         }
336     }
337
338     /**
4d81c9 339      * Retrieves the journal for the ticket.
JM 340      *
341      * @param repository
342      * @param ticketId
343      * @return a journal, if it exists, otherwise null
344      */
345     @Override
346     protected List<Change> getJournalImpl(RepositoryModel repository, long ticketId) {
347         Repository db = repositoryManager.getRepository(repository.name);
348         try {
349             List<Change> changes = getJournal(db, ticketId);
350             if (ArrayUtils.isEmpty(changes)) {
351                 log.warn("Empty journal for {}:{}", repository, ticketId);
352                 return null;
353             }
354             return changes;
355         } finally {
356             db.close();
357         }
358     }
359
360     /**
5e3521 361      * Returns the journal for the specified ticket.
JM 362      *
363      * @param db
364      * @param ticketId
365      * @return a list of changes
366      */
367     private List<Change> getJournal(Repository db, long ticketId) {
368         if (ticketId <= 0L) {
369             return new ArrayList<Change>();
370         }
371
372         String journalPath = toTicketPath(ticketId) + "/" + JOURNAL;
373         File journal = new File(db.getDirectory(), journalPath);
374         if (!journal.exists()) {
375             return new ArrayList<Change>();
376         }
377
378         String json = null;
379         try {
380             json = new String(FileUtils.readContent(journal), Constants.ENCODING);
381         } catch (Exception e) {
382             log.error(null, e);
383         }
384         if (StringUtils.isEmpty(json)) {
385             return new ArrayList<Change>();
386         }
387         List<Change> list = TicketSerializer.deserializeJournal(json);
388         return list;
389     }
390
391     @Override
392     public boolean supportsAttachments() {
393         return true;
394     }
395
396     /**
397      * Retrieves the specified attachment from a ticket.
398      *
399      * @param repository
400      * @param ticketId
401      * @param filename
402      * @return an attachment, if found, null otherwise
403      */
404     @Override
405     public Attachment getAttachment(RepositoryModel repository, long ticketId, String filename) {
406         if (ticketId <= 0L) {
407             return null;
408         }
409
410         // deserialize the ticket model so that we have the attachment metadata
411         TicketModel ticket = getTicket(repository, ticketId);
412         Attachment attachment = ticket.getAttachment(filename);
413
414         // attachment not found
415         if (attachment == null) {
416             return null;
417         }
418
419         // retrieve the attachment content
420         Repository db = repositoryManager.getRepository(repository.name);
421         try {
422             String attachmentPath = toAttachmentPath(ticketId, attachment.name);
423             File file = new File(db.getDirectory(), attachmentPath);
424             if (file.exists()) {
425                 attachment.content = FileUtils.readContent(file);
426                 attachment.size = attachment.content.length;
427             }
428             return attachment;
429         } finally {
430             db.close();
431         }
432     }
433
434     /**
435      * Deletes a ticket from the repository.
436      *
437      * @param ticket
438      * @return true if successful
439      */
440     @Override
441     protected synchronized boolean deleteTicketImpl(RepositoryModel repository, TicketModel ticket, String deletedBy) {
442         if (ticket == null) {
443             throw new RuntimeException("must specify a ticket!");
444         }
445
446         boolean success = false;
447         Repository db = repositoryManager.getRepository(ticket.repository);
448         try {
449             String ticketPath = toTicketPath(ticket.number);
450             File dir = new File(db.getDirectory(), ticketPath);
451             if (dir.exists()) {
452                 success = FileUtils.delete(dir);
453             }
454             success = true;
455         } finally {
456             db.close();
457         }
458         return success;
459     }
460
461     /**
462      * Commit a ticket change to the repository.
463      *
464      * @param repository
465      * @param ticketId
466      * @param change
467      * @return true, if the change was committed
468      */
469     @Override
470     protected synchronized boolean commitChangeImpl(RepositoryModel repository, long ticketId, Change change) {
471         boolean success = false;
472
473         Repository db = repositoryManager.getRepository(repository.name);
474         try {
475             List<Change> changes = getJournal(db, ticketId);
476             changes.add(change);
477             String journal = TicketSerializer.serializeJournal(changes).trim();
478
479             String journalPath = toTicketPath(ticketId) + "/" + JOURNAL;
480             File file = new File(db.getDirectory(), journalPath);
481             file.getParentFile().mkdirs();
482             FileUtils.writeContent(file, journal);
483             success = true;
484         } catch (Throwable t) {
485             log.error(MessageFormat.format("Failed to commit ticket {0,number,0} to {1}",
486                     ticketId, db.getDirectory()), t);
487         } finally {
488             db.close();
489         }
490         return success;
491     }
492
493     @Override
494     protected boolean deleteAllImpl(RepositoryModel repository) {
495         Repository db = repositoryManager.getRepository(repository.name);
48ca8a 496         if (db == null) {
JM 497             // the tickets no longer exist because the db no longer exists
498             return true;
499         }
5e3521 500         try {
JM 501             File dir = new File(db.getDirectory(), TICKETS_PATH);
502             return FileUtils.delete(dir);
503         } catch (Exception e) {
504             log.error(null, e);
505         } finally {
506             db.close();
507         }
508         return false;
509     }
510
511     @Override
512     protected boolean renameImpl(RepositoryModel oldRepository, RepositoryModel newRepository) {
513         return true;
514     }
515
516     @Override
517     public String toString() {
518         return getClass().getSimpleName();
519     }
520 }