You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

FileTicketService.java 13KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. /*
  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. import java.io.File;
  18. import java.io.IOException;
  19. import java.text.MessageFormat;
  20. import java.util.ArrayList;
  21. import java.util.Collections;
  22. import java.util.List;
  23. import java.util.Map;
  24. import java.util.concurrent.ConcurrentHashMap;
  25. import java.util.concurrent.atomic.AtomicLong;
  26. import javax.inject.Inject;
  27. import org.eclipse.jgit.lib.Repository;
  28. import com.gitblit.Constants;
  29. import com.gitblit.manager.INotificationManager;
  30. import com.gitblit.manager.IRepositoryManager;
  31. import com.gitblit.manager.IRuntimeManager;
  32. import com.gitblit.manager.IUserManager;
  33. import com.gitblit.models.RepositoryModel;
  34. import com.gitblit.models.TicketModel;
  35. import com.gitblit.models.TicketModel.Attachment;
  36. import com.gitblit.models.TicketModel.Change;
  37. import com.gitblit.utils.ArrayUtils;
  38. import com.gitblit.utils.FileUtils;
  39. import com.gitblit.utils.StringUtils;
  40. /**
  41. * Implementation of a ticket service based on a directory within the repository.
  42. * All tickets are serialized as a list of JSON changes and persisted in a hashed
  43. * directory structure, similar to the standard git loose object structure.
  44. *
  45. * @author James Moger
  46. *
  47. */
  48. public class FileTicketService extends ITicketService {
  49. private static final String JOURNAL = "journal.json";
  50. private static final String TICKETS_PATH = "tickets/";
  51. private final Map<String, AtomicLong> lastAssignedId;
  52. @Inject
  53. public FileTicketService(
  54. IRuntimeManager runtimeManager,
  55. INotificationManager notificationManager,
  56. IUserManager userManager,
  57. IRepositoryManager repositoryManager) {
  58. super(runtimeManager,
  59. notificationManager,
  60. userManager,
  61. repositoryManager);
  62. lastAssignedId = new ConcurrentHashMap<String, AtomicLong>();
  63. }
  64. @Override
  65. public FileTicketService start() {
  66. return this;
  67. }
  68. @Override
  69. protected void resetCachesImpl() {
  70. lastAssignedId.clear();
  71. }
  72. @Override
  73. protected void resetCachesImpl(RepositoryModel repository) {
  74. if (lastAssignedId.containsKey(repository.name)) {
  75. lastAssignedId.get(repository.name).set(0);
  76. }
  77. }
  78. @Override
  79. protected void close() {
  80. }
  81. /**
  82. * Returns the ticket path. This follows the same scheme as Git's object
  83. * store path where the first two characters of the hash id are the root
  84. * folder with the remaining characters as a subfolder within that folder.
  85. *
  86. * @param ticketId
  87. * @return the root path of the ticket content in the ticket directory
  88. */
  89. private String toTicketPath(long ticketId) {
  90. StringBuilder sb = new StringBuilder();
  91. sb.append(TICKETS_PATH);
  92. long m = ticketId % 100L;
  93. if (m < 10) {
  94. sb.append('0');
  95. }
  96. sb.append(m);
  97. sb.append('/');
  98. sb.append(ticketId);
  99. return sb.toString();
  100. }
  101. /**
  102. * Returns the path to the attachment for the specified ticket.
  103. *
  104. * @param ticketId
  105. * @param filename
  106. * @return the path to the specified attachment
  107. */
  108. private String toAttachmentPath(long ticketId, String filename) {
  109. return toTicketPath(ticketId) + "/attachments/" + filename;
  110. }
  111. /**
  112. * Ensures that we have a ticket for this ticket id.
  113. *
  114. * @param repository
  115. * @param ticketId
  116. * @return true if the ticket exists
  117. */
  118. @Override
  119. public boolean hasTicket(RepositoryModel repository, long ticketId) {
  120. boolean hasTicket = false;
  121. Repository db = repositoryManager.getRepository(repository.name);
  122. try {
  123. String journalPath = toTicketPath(ticketId) + "/" + JOURNAL;
  124. hasTicket = new File(db.getDirectory(), journalPath).exists();
  125. } finally {
  126. db.close();
  127. }
  128. return hasTicket;
  129. }
  130. /**
  131. * Assigns a new ticket id.
  132. *
  133. * @param repository
  134. * @return a new long id
  135. */
  136. @Override
  137. public synchronized long assignNewId(RepositoryModel repository) {
  138. long newId = 0L;
  139. Repository db = repositoryManager.getRepository(repository.name);
  140. try {
  141. if (!lastAssignedId.containsKey(repository.name)) {
  142. lastAssignedId.put(repository.name, new AtomicLong(0));
  143. }
  144. AtomicLong lastId = lastAssignedId.get(repository.name);
  145. if (lastId.get() <= 0) {
  146. // identify current highest ticket id by scanning the paths in the tip tree
  147. File dir = new File(db.getDirectory(), TICKETS_PATH);
  148. dir.mkdirs();
  149. List<File> journals = findAll(dir, JOURNAL);
  150. for (File journal : journals) {
  151. // Reconstruct ticketId from the path
  152. // id/26/326/journal.json
  153. String path = FileUtils.getRelativePath(dir, journal);
  154. String tid = path.split("/")[1];
  155. long ticketId = Long.parseLong(tid);
  156. if (ticketId > lastId.get()) {
  157. lastId.set(ticketId);
  158. }
  159. }
  160. }
  161. // assign the id and touch an empty journal to hold it's place
  162. newId = lastId.incrementAndGet();
  163. String journalPath = toTicketPath(newId) + "/" + JOURNAL;
  164. File journal = new File(db.getDirectory(), journalPath);
  165. journal.getParentFile().mkdirs();
  166. journal.createNewFile();
  167. } catch (IOException e) {
  168. log.error("failed to assign ticket id", e);
  169. return 0L;
  170. } finally {
  171. db.close();
  172. }
  173. return newId;
  174. }
  175. /**
  176. * Returns all the tickets in the repository. Querying tickets from the
  177. * repository requires deserializing all tickets. This is an expensive
  178. * process and not recommended. Tickets are indexed by Lucene and queries
  179. * should be executed against that index.
  180. *
  181. * @param repository
  182. * @param filter
  183. * optional filter to only return matching results
  184. * @return a list of tickets
  185. */
  186. @Override
  187. public List<TicketModel> getTickets(RepositoryModel repository, TicketFilter filter) {
  188. List<TicketModel> list = new ArrayList<TicketModel>();
  189. Repository db = repositoryManager.getRepository(repository.name);
  190. try {
  191. // Collect the set of all json files
  192. File dir = new File(db.getDirectory(), TICKETS_PATH);
  193. List<File> journals = findAll(dir, JOURNAL);
  194. // Deserialize each ticket and optionally filter out unwanted tickets
  195. for (File journal : journals) {
  196. String json = null;
  197. try {
  198. json = new String(FileUtils.readContent(journal), Constants.ENCODING);
  199. } catch (Exception e) {
  200. log.error(null, e);
  201. }
  202. if (StringUtils.isEmpty(json)) {
  203. // journal was touched but no changes were written
  204. continue;
  205. }
  206. try {
  207. // Reconstruct ticketId from the path
  208. // id/26/326/journal.json
  209. String path = FileUtils.getRelativePath(dir, journal);
  210. String tid = path.split("/")[1];
  211. long ticketId = Long.parseLong(tid);
  212. List<Change> changes = TicketSerializer.deserializeJournal(json);
  213. if (ArrayUtils.isEmpty(changes)) {
  214. log.warn("Empty journal for {}:{}", repository, journal);
  215. continue;
  216. }
  217. TicketModel ticket = TicketModel.buildTicket(changes);
  218. ticket.project = repository.projectPath;
  219. ticket.repository = repository.name;
  220. ticket.number = ticketId;
  221. // add the ticket, conditionally, to the list
  222. if (filter == null) {
  223. list.add(ticket);
  224. } else {
  225. if (filter.accept(ticket)) {
  226. list.add(ticket);
  227. }
  228. }
  229. } catch (Exception e) {
  230. log.error("failed to deserialize {}/{}\n{}",
  231. new Object [] { repository, journal, e.getMessage()});
  232. log.error(null, e);
  233. }
  234. }
  235. // sort the tickets by creation
  236. Collections.sort(list);
  237. return list;
  238. } finally {
  239. db.close();
  240. }
  241. }
  242. private List<File> findAll(File dir, String filename) {
  243. List<File> list = new ArrayList<File>();
  244. for (File file : dir.listFiles()) {
  245. if (file.isDirectory()) {
  246. list.addAll(findAll(file, filename));
  247. } else if (file.isFile()) {
  248. if (file.getName().equals(filename)) {
  249. list.add(file);
  250. }
  251. }
  252. }
  253. return list;
  254. }
  255. /**
  256. * Retrieves the ticket from the repository by first looking-up the changeId
  257. * associated with the ticketId.
  258. *
  259. * @param repository
  260. * @param ticketId
  261. * @return a ticket, if it exists, otherwise null
  262. */
  263. @Override
  264. protected TicketModel getTicketImpl(RepositoryModel repository, long ticketId) {
  265. Repository db = repositoryManager.getRepository(repository.name);
  266. try {
  267. List<Change> changes = getJournal(db, ticketId);
  268. if (ArrayUtils.isEmpty(changes)) {
  269. log.warn("Empty journal for {}:{}", repository, ticketId);
  270. return null;
  271. }
  272. TicketModel ticket = TicketModel.buildTicket(changes);
  273. if (ticket != null) {
  274. ticket.project = repository.projectPath;
  275. ticket.repository = repository.name;
  276. ticket.number = ticketId;
  277. }
  278. return ticket;
  279. } finally {
  280. db.close();
  281. }
  282. }
  283. /**
  284. * Returns the journal for the specified ticket.
  285. *
  286. * @param db
  287. * @param ticketId
  288. * @return a list of changes
  289. */
  290. private List<Change> getJournal(Repository db, long ticketId) {
  291. if (ticketId <= 0L) {
  292. return new ArrayList<Change>();
  293. }
  294. String journalPath = toTicketPath(ticketId) + "/" + JOURNAL;
  295. File journal = new File(db.getDirectory(), journalPath);
  296. if (!journal.exists()) {
  297. return new ArrayList<Change>();
  298. }
  299. String json = null;
  300. try {
  301. json = new String(FileUtils.readContent(journal), Constants.ENCODING);
  302. } catch (Exception e) {
  303. log.error(null, e);
  304. }
  305. if (StringUtils.isEmpty(json)) {
  306. return new ArrayList<Change>();
  307. }
  308. List<Change> list = TicketSerializer.deserializeJournal(json);
  309. return list;
  310. }
  311. @Override
  312. public boolean supportsAttachments() {
  313. return true;
  314. }
  315. /**
  316. * Retrieves the specified attachment from a ticket.
  317. *
  318. * @param repository
  319. * @param ticketId
  320. * @param filename
  321. * @return an attachment, if found, null otherwise
  322. */
  323. @Override
  324. public Attachment getAttachment(RepositoryModel repository, long ticketId, String filename) {
  325. if (ticketId <= 0L) {
  326. return null;
  327. }
  328. // deserialize the ticket model so that we have the attachment metadata
  329. TicketModel ticket = getTicket(repository, ticketId);
  330. Attachment attachment = ticket.getAttachment(filename);
  331. // attachment not found
  332. if (attachment == null) {
  333. return null;
  334. }
  335. // retrieve the attachment content
  336. Repository db = repositoryManager.getRepository(repository.name);
  337. try {
  338. String attachmentPath = toAttachmentPath(ticketId, attachment.name);
  339. File file = new File(db.getDirectory(), attachmentPath);
  340. if (file.exists()) {
  341. attachment.content = FileUtils.readContent(file);
  342. attachment.size = attachment.content.length;
  343. }
  344. return attachment;
  345. } finally {
  346. db.close();
  347. }
  348. }
  349. /**
  350. * Deletes a ticket from the repository.
  351. *
  352. * @param ticket
  353. * @return true if successful
  354. */
  355. @Override
  356. protected synchronized boolean deleteTicketImpl(RepositoryModel repository, TicketModel ticket, String deletedBy) {
  357. if (ticket == null) {
  358. throw new RuntimeException("must specify a ticket!");
  359. }
  360. boolean success = false;
  361. Repository db = repositoryManager.getRepository(ticket.repository);
  362. try {
  363. String ticketPath = toTicketPath(ticket.number);
  364. File dir = new File(db.getDirectory(), ticketPath);
  365. if (dir.exists()) {
  366. success = FileUtils.delete(dir);
  367. }
  368. success = true;
  369. } finally {
  370. db.close();
  371. }
  372. return success;
  373. }
  374. /**
  375. * Commit a ticket change to the repository.
  376. *
  377. * @param repository
  378. * @param ticketId
  379. * @param change
  380. * @return true, if the change was committed
  381. */
  382. @Override
  383. protected synchronized boolean commitChangeImpl(RepositoryModel repository, long ticketId, Change change) {
  384. boolean success = false;
  385. Repository db = repositoryManager.getRepository(repository.name);
  386. try {
  387. List<Change> changes = getJournal(db, ticketId);
  388. changes.add(change);
  389. String journal = TicketSerializer.serializeJournal(changes).trim();
  390. String journalPath = toTicketPath(ticketId) + "/" + JOURNAL;
  391. File file = new File(db.getDirectory(), journalPath);
  392. file.getParentFile().mkdirs();
  393. FileUtils.writeContent(file, journal);
  394. success = true;
  395. } catch (Throwable t) {
  396. log.error(MessageFormat.format("Failed to commit ticket {0,number,0} to {1}",
  397. ticketId, db.getDirectory()), t);
  398. } finally {
  399. db.close();
  400. }
  401. return success;
  402. }
  403. @Override
  404. protected boolean deleteAllImpl(RepositoryModel repository) {
  405. Repository db = repositoryManager.getRepository(repository.name);
  406. try {
  407. File dir = new File(db.getDirectory(), TICKETS_PATH);
  408. return FileUtils.delete(dir);
  409. } catch (Exception e) {
  410. log.error(null, e);
  411. } finally {
  412. db.close();
  413. }
  414. return false;
  415. }
  416. @Override
  417. protected boolean renameImpl(RepositoryModel oldRepository, RepositoryModel newRepository) {
  418. return true;
  419. }
  420. @Override
  421. public String toString() {
  422. return getClass().getSimpleName();
  423. }
  424. }