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.

ITicketService.java 39KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426
  1. /*
  2. * Copyright 2013 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.IOException;
  18. import java.text.MessageFormat;
  19. import java.text.ParseException;
  20. import java.text.SimpleDateFormat;
  21. import java.util.ArrayList;
  22. import java.util.BitSet;
  23. import java.util.Collections;
  24. import java.util.List;
  25. import java.util.Map;
  26. import java.util.Set;
  27. import java.util.concurrent.ConcurrentHashMap;
  28. import java.util.concurrent.TimeUnit;
  29. import org.eclipse.jgit.lib.Repository;
  30. import org.eclipse.jgit.lib.StoredConfig;
  31. import org.slf4j.Logger;
  32. import org.slf4j.LoggerFactory;
  33. import com.gitblit.IStoredSettings;
  34. import com.gitblit.Keys;
  35. import com.gitblit.extensions.TicketHook;
  36. import com.gitblit.manager.IManager;
  37. import com.gitblit.manager.INotificationManager;
  38. import com.gitblit.manager.IPluginManager;
  39. import com.gitblit.manager.IRepositoryManager;
  40. import com.gitblit.manager.IRuntimeManager;
  41. import com.gitblit.manager.IUserManager;
  42. import com.gitblit.models.RepositoryModel;
  43. import com.gitblit.models.TicketModel;
  44. import com.gitblit.models.TicketModel.Attachment;
  45. import com.gitblit.models.TicketModel.Change;
  46. import com.gitblit.models.TicketModel.Field;
  47. import com.gitblit.models.TicketModel.Patchset;
  48. import com.gitblit.models.TicketModel.PatchsetType;
  49. import com.gitblit.models.TicketModel.Status;
  50. import com.gitblit.models.TicketModel.TicketLink;
  51. import com.gitblit.tickets.TicketIndexer.Lucene;
  52. import com.gitblit.utils.DeepCopier;
  53. import com.gitblit.utils.DiffUtils;
  54. import com.gitblit.utils.JGitUtils;
  55. import com.gitblit.utils.DiffUtils.DiffStat;
  56. import com.gitblit.utils.StringUtils;
  57. import com.google.common.cache.Cache;
  58. import com.google.common.cache.CacheBuilder;
  59. /**
  60. * Abstract parent class of a ticket service that stubs out required methods
  61. * and transparently handles Lucene indexing.
  62. *
  63. * @author James Moger
  64. *
  65. */
  66. public abstract class ITicketService implements IManager {
  67. public static final String SETTING_UPDATE_DIFFSTATS = "migration.updateDiffstats";
  68. private static final String LABEL = "label";
  69. private static final String MILESTONE = "milestone";
  70. private static final String STATUS = "status";
  71. private static final String COLOR = "color";
  72. private static final String DUE = "due";
  73. private static final String DUE_DATE_PATTERN = "yyyy-MM-dd";
  74. /**
  75. * Object filter interface to querying against all available ticket models.
  76. */
  77. public interface TicketFilter {
  78. boolean accept(TicketModel ticket);
  79. }
  80. protected final Logger log;
  81. protected final IStoredSettings settings;
  82. protected final IRuntimeManager runtimeManager;
  83. protected final INotificationManager notificationManager;
  84. protected final IUserManager userManager;
  85. protected final IRepositoryManager repositoryManager;
  86. protected final IPluginManager pluginManager;
  87. protected final TicketIndexer indexer;
  88. private final Cache<TicketKey, TicketModel> ticketsCache;
  89. private final Map<String, List<TicketLabel>> labelsCache;
  90. private final Map<String, List<TicketMilestone>> milestonesCache;
  91. private final boolean updateDiffstats;
  92. private static class TicketKey {
  93. final String repository;
  94. final long ticketId;
  95. TicketKey(RepositoryModel repository, long ticketId) {
  96. this.repository = repository.name;
  97. this.ticketId = ticketId;
  98. }
  99. @Override
  100. public int hashCode() {
  101. return (repository + ticketId).hashCode();
  102. }
  103. @Override
  104. public boolean equals(Object o) {
  105. if (o instanceof TicketKey) {
  106. return o.hashCode() == hashCode();
  107. }
  108. return false;
  109. }
  110. @Override
  111. public String toString() {
  112. return repository + ":" + ticketId;
  113. }
  114. }
  115. /**
  116. * Creates a ticket service.
  117. */
  118. public ITicketService(
  119. IRuntimeManager runtimeManager,
  120. IPluginManager pluginManager,
  121. INotificationManager notificationManager,
  122. IUserManager userManager,
  123. IRepositoryManager repositoryManager) {
  124. this.log = LoggerFactory.getLogger(getClass());
  125. this.settings = runtimeManager.getSettings();
  126. this.runtimeManager = runtimeManager;
  127. this.pluginManager = pluginManager;
  128. this.notificationManager = notificationManager;
  129. this.userManager = userManager;
  130. this.repositoryManager = repositoryManager;
  131. this.indexer = new TicketIndexer(runtimeManager);
  132. CacheBuilder<Object, Object> cb = CacheBuilder.newBuilder();
  133. this.ticketsCache = cb
  134. .maximumSize(1000)
  135. .expireAfterAccess(30, TimeUnit.MINUTES)
  136. .build();
  137. this.labelsCache = new ConcurrentHashMap<String, List<TicketLabel>>();
  138. this.milestonesCache = new ConcurrentHashMap<String, List<TicketMilestone>>();
  139. this.updateDiffstats = settings.getBoolean(SETTING_UPDATE_DIFFSTATS, true);
  140. }
  141. /**
  142. * Start the service.
  143. * @since 1.4.0
  144. */
  145. @Override
  146. public final ITicketService start() {
  147. onStart();
  148. if (shouldReindex()) {
  149. log.info("Re-indexing all tickets...");
  150. // long startTime = System.currentTimeMillis();
  151. reindex();
  152. // float duration = (System.currentTimeMillis() - startTime) / 1000f;
  153. // log.info("Built Lucene index over all tickets in {} secs", duration);
  154. }
  155. return this;
  156. }
  157. /**
  158. * Start the specific ticket service implementation.
  159. *
  160. * @since 1.9.0
  161. */
  162. public abstract void onStart();
  163. /**
  164. * Stop the service.
  165. * @since 1.4.0
  166. */
  167. @Override
  168. public final ITicketService stop() {
  169. indexer.close();
  170. ticketsCache.invalidateAll();
  171. repositoryManager.closeAll();
  172. close();
  173. return this;
  174. }
  175. /**
  176. * Closes any open resources used by this service.
  177. * @since 1.4.0
  178. */
  179. protected abstract void close();
  180. /**
  181. * Creates a ticket notifier. The ticket notifier is not thread-safe!
  182. * @since 1.4.0
  183. */
  184. public TicketNotifier createNotifier() {
  185. return new TicketNotifier(
  186. runtimeManager,
  187. notificationManager,
  188. userManager,
  189. repositoryManager,
  190. this);
  191. }
  192. /**
  193. * Returns the ready status of the ticket service.
  194. *
  195. * @return true if the ticket service is ready
  196. * @since 1.4.0
  197. */
  198. public boolean isReady() {
  199. return true;
  200. }
  201. /**
  202. * Returns true if the new patchsets can be accepted for this repository.
  203. *
  204. * @param repository
  205. * @return true if patchsets are being accepted
  206. * @since 1.4.0
  207. */
  208. public boolean isAcceptingNewPatchsets(RepositoryModel repository) {
  209. return isReady()
  210. && settings.getBoolean(Keys.tickets.acceptNewPatchsets, true)
  211. && repository.acceptNewPatchsets
  212. && isAcceptingTicketUpdates(repository);
  213. }
  214. /**
  215. * Returns true if new tickets can be manually created for this repository.
  216. * This is separate from accepting patchsets.
  217. *
  218. * @param repository
  219. * @return true if tickets are being accepted
  220. * @since 1.4.0
  221. */
  222. public boolean isAcceptingNewTickets(RepositoryModel repository) {
  223. return isReady()
  224. && settings.getBoolean(Keys.tickets.acceptNewTickets, true)
  225. && repository.acceptNewTickets
  226. && isAcceptingTicketUpdates(repository);
  227. }
  228. /**
  229. * Returns true if ticket updates are allowed for this repository.
  230. *
  231. * @param repository
  232. * @return true if tickets are allowed to be updated
  233. * @since 1.4.0
  234. */
  235. public boolean isAcceptingTicketUpdates(RepositoryModel repository) {
  236. return isReady()
  237. && repository.hasCommits
  238. && repository.isBare
  239. && !repository.isFrozen
  240. && !repository.isMirror;
  241. }
  242. /**
  243. * Returns true if the repository has any tickets
  244. * @param repository
  245. * @return true if the repository has tickets
  246. * @since 1.4.0
  247. */
  248. public boolean hasTickets(RepositoryModel repository) {
  249. return indexer.hasTickets(repository);
  250. }
  251. /**
  252. * Reset all caches in the service.
  253. * @since 1.4.0
  254. */
  255. public final synchronized void resetCaches() {
  256. ticketsCache.invalidateAll();
  257. labelsCache.clear();
  258. milestonesCache.clear();
  259. resetCachesImpl();
  260. }
  261. /**
  262. * Reset all caches in the service.
  263. * @since 1.4.0
  264. */
  265. protected abstract void resetCachesImpl();
  266. /**
  267. * Reset any caches for the repository in the service.
  268. * @since 1.4.0
  269. */
  270. public final synchronized void resetCaches(RepositoryModel repository) {
  271. List<TicketKey> repoKeys = new ArrayList<TicketKey>();
  272. for (TicketKey key : ticketsCache.asMap().keySet()) {
  273. if (key.repository.equals(repository.name)) {
  274. repoKeys.add(key);
  275. }
  276. }
  277. ticketsCache.invalidateAll(repoKeys);
  278. labelsCache.remove(repository.name);
  279. milestonesCache.remove(repository.name);
  280. resetCachesImpl(repository);
  281. }
  282. /**
  283. * Reset the caches for the specified repository.
  284. *
  285. * @param repository
  286. * @since 1.4.0
  287. */
  288. protected abstract void resetCachesImpl(RepositoryModel repository);
  289. /**
  290. * Returns the list of labels for the repository.
  291. *
  292. * @param repository
  293. * @return the list of labels
  294. * @since 1.4.0
  295. */
  296. public List<TicketLabel> getLabels(RepositoryModel repository) {
  297. String key = repository.name;
  298. if (labelsCache.containsKey(key)) {
  299. return labelsCache.get(key);
  300. }
  301. List<TicketLabel> list = new ArrayList<TicketLabel>();
  302. Repository db = repositoryManager.getRepository(repository.name);
  303. try {
  304. StoredConfig config = db.getConfig();
  305. Set<String> names = config.getSubsections(LABEL);
  306. for (String name : names) {
  307. TicketLabel label = new TicketLabel(name);
  308. label.color = config.getString(LABEL, name, COLOR);
  309. list.add(label);
  310. }
  311. labelsCache.put(key, Collections.unmodifiableList(list));
  312. } catch (Exception e) {
  313. log.error("invalid tickets settings for {}", repository, e);
  314. } finally {
  315. db.close();
  316. }
  317. return list;
  318. }
  319. /**
  320. * Returns a TicketLabel object for a given label. If the label is not
  321. * found, a ticket label object is created.
  322. *
  323. * @param repository
  324. * @param label
  325. * @return a TicketLabel
  326. * @since 1.4.0
  327. */
  328. public TicketLabel getLabel(RepositoryModel repository, String label) {
  329. for (TicketLabel tl : getLabels(repository)) {
  330. if (tl.name.equalsIgnoreCase(label)) {
  331. String q = QueryBuilder.q(Lucene.rid.matches(repository.getRID())).and(Lucene.labels.matches(label)).build();
  332. tl.tickets = indexer.queryFor(q, 1, 0, Lucene.number.name(), true);
  333. return tl;
  334. }
  335. }
  336. return new TicketLabel(label);
  337. }
  338. /**
  339. * Creates a label.
  340. *
  341. * @param repository
  342. * @param milestone
  343. * @param createdBy
  344. * @return the label
  345. * @since 1.4.0
  346. */
  347. public synchronized TicketLabel createLabel(RepositoryModel repository, String label, String createdBy) {
  348. TicketLabel lb = new TicketMilestone(label);
  349. Repository db = null;
  350. try {
  351. db = repositoryManager.getRepository(repository.name);
  352. StoredConfig config = db.getConfig();
  353. config.setString(LABEL, label, COLOR, lb.color);
  354. config.save();
  355. } catch (IOException e) {
  356. log.error("failed to create label {} in {}", label, repository, e);
  357. } finally {
  358. if (db != null) {
  359. db.close();
  360. }
  361. }
  362. return lb;
  363. }
  364. /**
  365. * Updates a label.
  366. *
  367. * @param repository
  368. * @param label
  369. * @param createdBy
  370. * @return true if the update was successful
  371. * @since 1.4.0
  372. */
  373. public synchronized boolean updateLabel(RepositoryModel repository, TicketLabel label, String createdBy) {
  374. Repository db = null;
  375. try {
  376. db = repositoryManager.getRepository(repository.name);
  377. StoredConfig config = db.getConfig();
  378. config.setString(LABEL, label.name, COLOR, label.color);
  379. config.save();
  380. return true;
  381. } catch (IOException e) {
  382. log.error("failed to update label {} in {}", label, repository, e);
  383. } finally {
  384. if (db != null) {
  385. db.close();
  386. }
  387. }
  388. return false;
  389. }
  390. /**
  391. * Renames a label.
  392. *
  393. * @param repository
  394. * @param oldName
  395. * @param newName
  396. * @param createdBy
  397. * @return true if the rename was successful
  398. * @since 1.4.0
  399. */
  400. public synchronized boolean renameLabel(RepositoryModel repository, String oldName, String newName, String createdBy) {
  401. if (StringUtils.isEmpty(newName)) {
  402. throw new IllegalArgumentException("new label can not be empty!");
  403. }
  404. Repository db = null;
  405. try {
  406. db = repositoryManager.getRepository(repository.name);
  407. TicketLabel label = getLabel(repository, oldName);
  408. StoredConfig config = db.getConfig();
  409. config.unsetSection(LABEL, oldName);
  410. config.setString(LABEL, newName, COLOR, label.color);
  411. config.save();
  412. for (QueryResult qr : label.tickets) {
  413. Change change = new Change(createdBy);
  414. change.unlabel(oldName);
  415. change.label(newName);
  416. updateTicket(repository, qr.number, change);
  417. }
  418. return true;
  419. } catch (IOException e) {
  420. log.error("failed to rename label {} in {}", oldName, repository, e);
  421. } finally {
  422. if (db != null) {
  423. db.close();
  424. }
  425. }
  426. return false;
  427. }
  428. /**
  429. * Deletes a label.
  430. *
  431. * @param repository
  432. * @param label
  433. * @param createdBy
  434. * @return true if the delete was successful
  435. * @since 1.4.0
  436. */
  437. public synchronized boolean deleteLabel(RepositoryModel repository, String label, String createdBy) {
  438. if (StringUtils.isEmpty(label)) {
  439. throw new IllegalArgumentException("label can not be empty!");
  440. }
  441. Repository db = null;
  442. try {
  443. db = repositoryManager.getRepository(repository.name);
  444. StoredConfig config = db.getConfig();
  445. config.unsetSection(LABEL, label);
  446. config.save();
  447. return true;
  448. } catch (IOException e) {
  449. log.error("failed to delete label {} in {}", label, repository, e);
  450. } finally {
  451. if (db != null) {
  452. db.close();
  453. }
  454. }
  455. return false;
  456. }
  457. /**
  458. * Returns the list of milestones for the repository.
  459. *
  460. * @param repository
  461. * @return the list of milestones
  462. * @since 1.4.0
  463. */
  464. public List<TicketMilestone> getMilestones(RepositoryModel repository) {
  465. String key = repository.name;
  466. if (milestonesCache.containsKey(key)) {
  467. return milestonesCache.get(key);
  468. }
  469. List<TicketMilestone> list = new ArrayList<TicketMilestone>();
  470. Repository db = repositoryManager.getRepository(repository.name);
  471. try {
  472. StoredConfig config = db.getConfig();
  473. Set<String> names = config.getSubsections(MILESTONE);
  474. for (String name : names) {
  475. TicketMilestone milestone = new TicketMilestone(name);
  476. milestone.status = Status.fromObject(config.getString(MILESTONE, name, STATUS), milestone.status);
  477. milestone.color = config.getString(MILESTONE, name, COLOR);
  478. String due = config.getString(MILESTONE, name, DUE);
  479. if (!StringUtils.isEmpty(due)) {
  480. try {
  481. milestone.due = new SimpleDateFormat(DUE_DATE_PATTERN).parse(due);
  482. } catch (ParseException e) {
  483. log.error("failed to parse {} milestone {} due date \"{}\"", repository, name, due, e);
  484. }
  485. }
  486. list.add(milestone);
  487. }
  488. milestonesCache.put(key, Collections.unmodifiableList(list));
  489. } catch (Exception e) {
  490. log.error("invalid tickets settings for {}", repository, e);
  491. } finally {
  492. db.close();
  493. }
  494. return list;
  495. }
  496. /**
  497. * Returns the list of milestones for the repository that match the status.
  498. *
  499. * @param repository
  500. * @param status
  501. * @return the list of milestones
  502. * @since 1.4.0
  503. */
  504. public List<TicketMilestone> getMilestones(RepositoryModel repository, Status status) {
  505. List<TicketMilestone> matches = new ArrayList<TicketMilestone>();
  506. for (TicketMilestone milestone : getMilestones(repository)) {
  507. if (status == milestone.status) {
  508. matches.add(milestone);
  509. }
  510. }
  511. return matches;
  512. }
  513. /**
  514. * Returns the specified milestone or null if the milestone does not exist.
  515. *
  516. * @param repository
  517. * @param milestone
  518. * @return the milestone or null if it does not exist
  519. * @since 1.4.0
  520. */
  521. public TicketMilestone getMilestone(RepositoryModel repository, String milestone) {
  522. for (TicketMilestone ms : getMilestones(repository)) {
  523. if (ms.name.equalsIgnoreCase(milestone)) {
  524. TicketMilestone tm = DeepCopier.copy(ms);
  525. String q = QueryBuilder.q(Lucene.rid.matches(repository.getRID())).and(Lucene.milestone.matches(milestone)).build();
  526. tm.tickets = indexer.queryFor(q, 1, 0, Lucene.number.name(), true);
  527. return tm;
  528. }
  529. }
  530. return null;
  531. }
  532. /**
  533. * Creates a milestone.
  534. *
  535. * @param repository
  536. * @param milestone
  537. * @param createdBy
  538. * @return the milestone
  539. * @since 1.4.0
  540. */
  541. public synchronized TicketMilestone createMilestone(RepositoryModel repository, String milestone, String createdBy) {
  542. TicketMilestone ms = new TicketMilestone(milestone);
  543. Repository db = null;
  544. try {
  545. db = repositoryManager.getRepository(repository.name);
  546. StoredConfig config = db.getConfig();
  547. config.setString(MILESTONE, milestone, STATUS, ms.status.name());
  548. config.setString(MILESTONE, milestone, COLOR, ms.color);
  549. config.save();
  550. milestonesCache.remove(repository.name);
  551. } catch (IOException e) {
  552. log.error("failed to create milestone {} in {}", milestone, repository, e);
  553. } finally {
  554. if (db != null) {
  555. db.close();
  556. }
  557. }
  558. return ms;
  559. }
  560. /**
  561. * Updates a milestone.
  562. *
  563. * @param repository
  564. * @param milestone
  565. * @param createdBy
  566. * @return true if successful
  567. * @since 1.4.0
  568. */
  569. public synchronized boolean updateMilestone(RepositoryModel repository, TicketMilestone milestone, String createdBy) {
  570. Repository db = null;
  571. try {
  572. db = repositoryManager.getRepository(repository.name);
  573. StoredConfig config = db.getConfig();
  574. config.setString(MILESTONE, milestone.name, STATUS, milestone.status.name());
  575. config.setString(MILESTONE, milestone.name, COLOR, milestone.color);
  576. if (milestone.due != null) {
  577. config.setString(MILESTONE, milestone.name, DUE,
  578. new SimpleDateFormat(DUE_DATE_PATTERN).format(milestone.due));
  579. }
  580. config.save();
  581. milestonesCache.remove(repository.name);
  582. return true;
  583. } catch (IOException e) {
  584. log.error("failed to update milestone {} in {}", milestone, repository, e);
  585. } finally {
  586. if (db != null) {
  587. db.close();
  588. }
  589. }
  590. return false;
  591. }
  592. /**
  593. * Renames a milestone.
  594. *
  595. * @param repository
  596. * @param oldName
  597. * @param newName
  598. * @param createdBy
  599. * @return true if successful
  600. * @since 1.4.0
  601. */
  602. public synchronized boolean renameMilestone(RepositoryModel repository, String oldName, String newName, String createdBy) {
  603. return renameMilestone(repository, oldName, newName, createdBy, true);
  604. }
  605. /**
  606. * Renames a milestone.
  607. *
  608. * @param repository
  609. * @param oldName
  610. * @param newName
  611. * @param createdBy
  612. * @param notifyOpenTickets
  613. * @return true if successful
  614. * @since 1.6.0
  615. */
  616. public synchronized boolean renameMilestone(RepositoryModel repository, String oldName,
  617. String newName, String createdBy, boolean notifyOpenTickets) {
  618. if (StringUtils.isEmpty(newName)) {
  619. throw new IllegalArgumentException("new milestone can not be empty!");
  620. }
  621. Repository db = null;
  622. try {
  623. db = repositoryManager.getRepository(repository.name);
  624. TicketMilestone tm = getMilestone(repository, oldName);
  625. if (tm == null) {
  626. return false;
  627. }
  628. StoredConfig config = db.getConfig();
  629. config.unsetSection(MILESTONE, oldName);
  630. config.setString(MILESTONE, newName, STATUS, tm.status.name());
  631. config.setString(MILESTONE, newName, COLOR, tm.color);
  632. if (tm.due != null) {
  633. config.setString(MILESTONE, newName, DUE,
  634. new SimpleDateFormat(DUE_DATE_PATTERN).format(tm.due));
  635. }
  636. config.save();
  637. milestonesCache.remove(repository.name);
  638. TicketNotifier notifier = createNotifier();
  639. for (QueryResult qr : tm.tickets) {
  640. Change change = new Change(createdBy);
  641. change.setField(Field.milestone, newName);
  642. TicketModel ticket = updateTicket(repository, qr.number, change);
  643. if (notifyOpenTickets && ticket.isOpen()) {
  644. notifier.queueMailing(ticket);
  645. }
  646. }
  647. if (notifyOpenTickets) {
  648. notifier.sendAll();
  649. }
  650. return true;
  651. } catch (IOException e) {
  652. log.error("failed to rename milestone {} in {}", oldName, repository, e);
  653. } finally {
  654. if (db != null) {
  655. db.close();
  656. }
  657. }
  658. return false;
  659. }
  660. /**
  661. * Deletes a milestone.
  662. *
  663. * @param repository
  664. * @param milestone
  665. * @param createdBy
  666. * @return true if successful
  667. * @since 1.4.0
  668. */
  669. public synchronized boolean deleteMilestone(RepositoryModel repository, String milestone, String createdBy) {
  670. return deleteMilestone(repository, milestone, createdBy, true);
  671. }
  672. /**
  673. * Deletes a milestone.
  674. *
  675. * @param repository
  676. * @param milestone
  677. * @param createdBy
  678. * @param notifyOpenTickets
  679. * @return true if successful
  680. * @since 1.6.0
  681. */
  682. public synchronized boolean deleteMilestone(RepositoryModel repository, String milestone,
  683. String createdBy, boolean notifyOpenTickets) {
  684. if (StringUtils.isEmpty(milestone)) {
  685. throw new IllegalArgumentException("milestone can not be empty!");
  686. }
  687. Repository db = null;
  688. try {
  689. TicketMilestone tm = getMilestone(repository, milestone);
  690. if (tm == null) {
  691. return false;
  692. }
  693. db = repositoryManager.getRepository(repository.name);
  694. StoredConfig config = db.getConfig();
  695. config.unsetSection(MILESTONE, milestone);
  696. config.save();
  697. milestonesCache.remove(repository.name);
  698. TicketNotifier notifier = createNotifier();
  699. for (QueryResult qr : tm.tickets) {
  700. Change change = new Change(createdBy);
  701. change.setField(Field.milestone, "");
  702. TicketModel ticket = updateTicket(repository, qr.number, change);
  703. if (notifyOpenTickets && ticket.isOpen()) {
  704. notifier.queueMailing(ticket);
  705. }
  706. }
  707. if (notifyOpenTickets) {
  708. notifier.sendAll();
  709. }
  710. return true;
  711. } catch (IOException e) {
  712. log.error("failed to delete milestone {} in {}", milestone, repository, e);
  713. } finally {
  714. if (db != null) {
  715. db.close();
  716. }
  717. }
  718. return false;
  719. }
  720. /**
  721. * Returns the set of assigned ticket ids in the repository.
  722. *
  723. * @param repository
  724. * @return a set of assigned ticket ids in the repository
  725. * @since 1.6.0
  726. */
  727. public abstract Set<Long> getIds(RepositoryModel repository);
  728. /**
  729. * Assigns a new ticket id.
  730. *
  731. * @param repository
  732. * @return a new ticket id
  733. * @since 1.4.0
  734. */
  735. public abstract long assignNewId(RepositoryModel repository);
  736. /**
  737. * Ensures that we have a ticket for this ticket id.
  738. *
  739. * @param repository
  740. * @param ticketId
  741. * @return true if the ticket exists
  742. * @since 1.4.0
  743. */
  744. public abstract boolean hasTicket(RepositoryModel repository, long ticketId);
  745. /**
  746. * Returns all tickets. This is not a Lucene search!
  747. *
  748. * @param repository
  749. * @return all tickets
  750. * @since 1.4.0
  751. */
  752. public List<TicketModel> getTickets(RepositoryModel repository) {
  753. return getTickets(repository, null);
  754. }
  755. /**
  756. * Returns all tickets that satisfy the filter. Retrieving tickets from the
  757. * service requires deserializing all journals and building ticket models.
  758. * This is an expensive process and not recommended. Instead, the queryFor
  759. * method should be used which executes against the Lucene index.
  760. *
  761. * @param repository
  762. * @param filter
  763. * optional issue filter to only return matching results
  764. * @return a list of tickets
  765. * @since 1.4.0
  766. */
  767. public abstract List<TicketModel> getTickets(RepositoryModel repository, TicketFilter filter);
  768. /**
  769. * Retrieves the ticket.
  770. *
  771. * @param repository
  772. * @param ticketId
  773. * @return a ticket, if it exists, otherwise null
  774. * @since 1.4.0
  775. */
  776. public final TicketModel getTicket(RepositoryModel repository, long ticketId) {
  777. TicketKey key = new TicketKey(repository, ticketId);
  778. TicketModel ticket = ticketsCache.getIfPresent(key);
  779. // if ticket not cached
  780. if (ticket == null) {
  781. //load ticket
  782. ticket = getTicketImpl(repository, ticketId);
  783. // if ticket exists
  784. if (ticket != null) {
  785. if (ticket.hasPatchsets() && updateDiffstats) {
  786. Repository r = repositoryManager.getRepository(repository.name);
  787. try {
  788. Patchset patchset = ticket.getCurrentPatchset();
  789. DiffStat diffStat = DiffUtils.getDiffStat(r, patchset.base, patchset.tip);
  790. // diffstat could be null if we have ticket data without the
  791. // commit objects. e.g. ticket replication without repo
  792. // mirroring
  793. if (diffStat != null) {
  794. ticket.insertions = diffStat.getInsertions();
  795. ticket.deletions = diffStat.getDeletions();
  796. }
  797. } finally {
  798. r.close();
  799. }
  800. }
  801. //cache ticket
  802. ticketsCache.put(key, ticket);
  803. }
  804. }
  805. return ticket;
  806. }
  807. /**
  808. * Retrieves the ticket.
  809. *
  810. * @param repository
  811. * @param ticketId
  812. * @return a ticket, if it exists, otherwise null
  813. * @since 1.4.0
  814. */
  815. protected abstract TicketModel getTicketImpl(RepositoryModel repository, long ticketId);
  816. /**
  817. * Returns the journal used to build a ticket.
  818. *
  819. * @param repository
  820. * @param ticketId
  821. * @return the journal for the ticket, if it exists, otherwise null
  822. * @since 1.6.0
  823. */
  824. public final List<Change> getJournal(RepositoryModel repository, long ticketId) {
  825. if (hasTicket(repository, ticketId)) {
  826. List<Change> journal = getJournalImpl(repository, ticketId);
  827. return journal;
  828. }
  829. return null;
  830. }
  831. /**
  832. * Retrieves the ticket journal.
  833. *
  834. * @param repository
  835. * @param ticketId
  836. * @return a ticket, if it exists, otherwise null
  837. * @since 1.6.0
  838. */
  839. protected abstract List<Change> getJournalImpl(RepositoryModel repository, long ticketId);
  840. /**
  841. * Get the ticket url
  842. *
  843. * @param ticket
  844. * @return the ticket url
  845. * @since 1.4.0
  846. */
  847. public String getTicketUrl(TicketModel ticket) {
  848. final String canonicalUrl = settings.getString(Keys.web.canonicalUrl, "https://localhost:8443");
  849. final String hrefPattern = "{0}/tickets?r={1}&h={2,number,0}";
  850. return MessageFormat.format(hrefPattern, canonicalUrl, ticket.repository, ticket.number);
  851. }
  852. /**
  853. * Get the compare url
  854. *
  855. * @param base
  856. * @param tip
  857. * @return the compare url
  858. * @since 1.4.0
  859. */
  860. public String getCompareUrl(TicketModel ticket, String base, String tip) {
  861. final String canonicalUrl = settings.getString(Keys.web.canonicalUrl, "https://localhost:8443");
  862. final String hrefPattern = "{0}/compare?r={1}&h={2}..{3}";
  863. return MessageFormat.format(hrefPattern, canonicalUrl, ticket.repository, base, tip);
  864. }
  865. /**
  866. * Returns true if attachments are supported.
  867. *
  868. * @return true if attachments are supported
  869. * @since 1.4.0
  870. */
  871. public abstract boolean supportsAttachments();
  872. /**
  873. * Retrieves the specified attachment from a ticket.
  874. *
  875. * @param repository
  876. * @param ticketId
  877. * @param filename
  878. * @return an attachment, if found, null otherwise
  879. * @since 1.4.0
  880. */
  881. public abstract Attachment getAttachment(RepositoryModel repository, long ticketId, String filename);
  882. /**
  883. * Creates a ticket. Your change must include a repository, author & title,
  884. * at a minimum. If your change does not have those minimum requirements a
  885. * RuntimeException will be thrown.
  886. *
  887. * @param repository
  888. * @param change
  889. * @return true if successful
  890. * @since 1.4.0
  891. */
  892. public TicketModel createTicket(RepositoryModel repository, Change change) {
  893. return createTicket(repository, 0L, change);
  894. }
  895. /**
  896. * Creates a ticket. Your change must include a repository, author & title,
  897. * at a minimum. If your change does not have those minimum requirements a
  898. * RuntimeException will be thrown.
  899. *
  900. * @param repository
  901. * @param ticketId (if <=0 the ticket id will be assigned)
  902. * @param change
  903. * @return true if successful
  904. * @since 1.4.0
  905. */
  906. public TicketModel createTicket(RepositoryModel repository, long ticketId, Change change) {
  907. if (repository == null) {
  908. throw new RuntimeException("Must specify a repository!");
  909. }
  910. if (StringUtils.isEmpty(change.author)) {
  911. throw new RuntimeException("Must specify a change author!");
  912. }
  913. if (!change.hasField(Field.title)) {
  914. throw new RuntimeException("Must specify a title!");
  915. }
  916. change.watch(change.author);
  917. if (ticketId <= 0L) {
  918. ticketId = assignNewId(repository);
  919. }
  920. change.setField(Field.status, Status.New);
  921. boolean success = commitChangeImpl(repository, ticketId, change);
  922. if (success) {
  923. TicketModel ticket = getTicket(repository, ticketId);
  924. indexer.index(ticket);
  925. // call the ticket hooks
  926. if (pluginManager != null) {
  927. for (TicketHook hook : pluginManager.getExtensions(TicketHook.class)) {
  928. try {
  929. hook.onNewTicket(ticket);
  930. } catch (Exception e) {
  931. log.error("Failed to execute extension", e);
  932. }
  933. }
  934. }
  935. return ticket;
  936. }
  937. return null;
  938. }
  939. /**
  940. * Updates a ticket and promotes pending links into references.
  941. *
  942. * @param repository
  943. * @param ticketId, or 0 to action pending links in general
  944. * @param change
  945. * @return the ticket model if successful, null if failure or using 0 ticketId
  946. * @since 1.4.0
  947. */
  948. public final TicketModel updateTicket(RepositoryModel repository, long ticketId, Change change) {
  949. if (change == null) {
  950. throw new RuntimeException("change can not be null!");
  951. }
  952. if (StringUtils.isEmpty(change.author)) {
  953. throw new RuntimeException("must specify a change author!");
  954. }
  955. boolean success = true;
  956. TicketModel ticket = null;
  957. if (ticketId > 0) {
  958. TicketKey key = new TicketKey(repository, ticketId);
  959. ticketsCache.invalidate(key);
  960. success = commitChangeImpl(repository, ticketId, change);
  961. if (success) {
  962. ticket = getTicket(repository, ticketId);
  963. ticketsCache.put(key, ticket);
  964. indexer.index(ticket);
  965. // call the ticket hooks
  966. if (pluginManager != null) {
  967. for (TicketHook hook : pluginManager.getExtensions(TicketHook.class)) {
  968. try {
  969. hook.onUpdateTicket(ticket, change);
  970. } catch (Exception e) {
  971. log.error("Failed to execute extension", e);
  972. }
  973. }
  974. }
  975. }
  976. }
  977. if (success) {
  978. //Now that the ticket has been successfully persisted add references to this ticket from linked tickets
  979. if (change.hasPendingLinks()) {
  980. for (TicketLink link : change.pendingLinks) {
  981. TicketModel linkedTicket = getTicket(repository, link.targetTicketId);
  982. Change dstChange = null;
  983. //Ignore if not available or self reference
  984. if (linkedTicket != null && link.targetTicketId != ticketId) {
  985. dstChange = new Change(change.author, change.date);
  986. switch (link.action) {
  987. case Comment: {
  988. if (ticketId == 0) {
  989. throw new RuntimeException("must specify a ticket when linking a comment!");
  990. }
  991. dstChange.referenceTicket(ticketId, change.comment.id);
  992. } break;
  993. case Commit: {
  994. dstChange.referenceCommit(link.hash);
  995. } break;
  996. default: {
  997. throw new RuntimeException(
  998. String.format("must add persist logic for link of type %s", link.action));
  999. }
  1000. }
  1001. }
  1002. if (dstChange != null) {
  1003. //If not deleted then remain null in journal
  1004. if (link.isDelete) {
  1005. dstChange.reference.deleted = true;
  1006. }
  1007. if (updateTicket(repository, link.targetTicketId, dstChange) != null) {
  1008. link.success = true;
  1009. }
  1010. }
  1011. }
  1012. }
  1013. }
  1014. return ticket;
  1015. }
  1016. /**
  1017. * Deletes all tickets in every repository.
  1018. *
  1019. * @return true if successful
  1020. * @since 1.4.0
  1021. */
  1022. public boolean deleteAll() {
  1023. List<String> repositories = repositoryManager.getRepositoryList();
  1024. BitSet bitset = new BitSet(repositories.size());
  1025. for (int i = 0; i < repositories.size(); i++) {
  1026. String name = repositories.get(i);
  1027. RepositoryModel repository = repositoryManager.getRepositoryModel(name);
  1028. boolean success = deleteAll(repository);
  1029. bitset.set(i, success);
  1030. }
  1031. boolean success = bitset.cardinality() == repositories.size();
  1032. if (success) {
  1033. indexer.deleteAll();
  1034. resetCaches();
  1035. }
  1036. return success;
  1037. }
  1038. /**
  1039. * Deletes all tickets in the specified repository.
  1040. * @param repository
  1041. * @return true if succesful
  1042. * @since 1.4.0
  1043. */
  1044. public boolean deleteAll(RepositoryModel repository) {
  1045. boolean success = deleteAllImpl(repository);
  1046. if (success) {
  1047. log.info("Deleted all tickets for {}", repository.name);
  1048. resetCaches(repository);
  1049. indexer.deleteAll(repository);
  1050. }
  1051. return success;
  1052. }
  1053. /**
  1054. * Delete all tickets for the specified repository.
  1055. * @param repository
  1056. * @return true if successful
  1057. * @since 1.4.0
  1058. */
  1059. protected abstract boolean deleteAllImpl(RepositoryModel repository);
  1060. /**
  1061. * Handles repository renames.
  1062. *
  1063. * @param oldRepositoryName
  1064. * @param newRepositoryName
  1065. * @return true if successful
  1066. * @since 1.4.0
  1067. */
  1068. public boolean rename(RepositoryModel oldRepository, RepositoryModel newRepository) {
  1069. if (renameImpl(oldRepository, newRepository)) {
  1070. resetCaches(oldRepository);
  1071. indexer.deleteAll(oldRepository);
  1072. reindex(newRepository);
  1073. return true;
  1074. }
  1075. return false;
  1076. }
  1077. /**
  1078. * Renames a repository.
  1079. *
  1080. * @param oldRepository
  1081. * @param newRepository
  1082. * @return true if successful
  1083. * @since 1.4.0
  1084. */
  1085. protected abstract boolean renameImpl(RepositoryModel oldRepository, RepositoryModel newRepository);
  1086. /**
  1087. * Deletes a ticket.
  1088. *
  1089. * @param repository
  1090. * @param ticketId
  1091. * @param deletedBy
  1092. * @return true if successful
  1093. * @since 1.4.0
  1094. */
  1095. public boolean deleteTicket(RepositoryModel repository, long ticketId, String deletedBy) {
  1096. TicketModel ticket = getTicket(repository, ticketId);
  1097. boolean success = deleteTicketImpl(repository, ticket, deletedBy);
  1098. if (success) {
  1099. log.info("Deleted {} ticket #{}: {}", repository.name, ticketId, ticket.title);
  1100. ticketsCache.invalidate(new TicketKey(repository, ticketId));
  1101. indexer.delete(ticket);
  1102. return true;
  1103. }
  1104. return false;
  1105. }
  1106. /**
  1107. * Deletes a ticket.
  1108. *
  1109. * @param repository
  1110. * @param ticket
  1111. * @param deletedBy
  1112. * @return true if successful
  1113. * @since 1.4.0
  1114. */
  1115. protected abstract boolean deleteTicketImpl(RepositoryModel repository, TicketModel ticket, String deletedBy);
  1116. /**
  1117. * Updates the text of an ticket comment.
  1118. *
  1119. * @param ticket
  1120. * @param commentId
  1121. * the id of the comment to revise
  1122. * @param updatedBy
  1123. * the author of the updated comment
  1124. * @param comment
  1125. * the revised comment
  1126. * @return the revised ticket if the change was successful
  1127. * @since 1.4.0
  1128. */
  1129. public final TicketModel updateComment(TicketModel ticket, String commentId,
  1130. String updatedBy, String comment) {
  1131. Change revision = new Change(updatedBy);
  1132. revision.comment(comment);
  1133. revision.comment.id = commentId;
  1134. RepositoryModel repository = repositoryManager.getRepositoryModel(ticket.repository);
  1135. TicketModel revisedTicket = updateTicket(repository, ticket.number, revision);
  1136. return revisedTicket;
  1137. }
  1138. /**
  1139. * Deletes a comment from a ticket.
  1140. *
  1141. * @param ticket
  1142. * @param commentId
  1143. * the id of the comment to delete
  1144. * @param deletedBy
  1145. * the user deleting the comment
  1146. * @return the revised ticket if the deletion was successful
  1147. * @since 1.4.0
  1148. */
  1149. public final TicketModel deleteComment(TicketModel ticket, String commentId, String deletedBy) {
  1150. Change deletion = new Change(deletedBy);
  1151. deletion.comment("");
  1152. deletion.comment.id = commentId;
  1153. deletion.comment.deleted = true;
  1154. RepositoryModel repository = repositoryManager.getRepositoryModel(ticket.repository);
  1155. TicketModel revisedTicket = updateTicket(repository, ticket.number, deletion);
  1156. return revisedTicket;
  1157. }
  1158. /**
  1159. * Deletes a patchset from a ticket.
  1160. *
  1161. * @param ticket
  1162. * @param patchset
  1163. * the patchset to delete (should be the highest revision)
  1164. * @param userName
  1165. * the user deleting the commit
  1166. * @return the revised ticket if the deletion was successful
  1167. * @since 1.8.0
  1168. */
  1169. public final TicketModel deletePatchset(TicketModel ticket, Patchset patchset, String userName) {
  1170. Change deletion = new Change(userName);
  1171. deletion.patchset = new Patchset();
  1172. deletion.patchset.number = patchset.number;
  1173. deletion.patchset.rev = patchset.rev;
  1174. deletion.patchset.type = PatchsetType.Delete;
  1175. //Find and delete references to tickets by the removed commits
  1176. List<TicketLink> patchsetTicketLinks = JGitUtils.identifyTicketsBetweenCommits(
  1177. repositoryManager.getRepository(ticket.repository),
  1178. settings, patchset.base, patchset.tip);
  1179. for (TicketLink link : patchsetTicketLinks) {
  1180. link.isDelete = true;
  1181. }
  1182. deletion.pendingLinks = patchsetTicketLinks;
  1183. RepositoryModel repositoryModel = repositoryManager.getRepositoryModel(ticket.repository);
  1184. TicketModel revisedTicket = updateTicket(repositoryModel, ticket.number, deletion);
  1185. return revisedTicket;
  1186. }
  1187. /**
  1188. * Commit a ticket change to the repository.
  1189. *
  1190. * @param repository
  1191. * @param ticketId
  1192. * @param change
  1193. * @return true, if the change was committed
  1194. * @since 1.4.0
  1195. */
  1196. protected abstract boolean commitChangeImpl(RepositoryModel repository, long ticketId, Change change);
  1197. /**
  1198. * Searches for the specified text. This will use the indexer, if available,
  1199. * or will fall back to brute-force retrieval of all tickets and string
  1200. * matching.
  1201. *
  1202. * @param repository
  1203. * @param text
  1204. * @param page
  1205. * @param pageSize
  1206. * @return a list of matching tickets
  1207. * @since 1.4.0
  1208. */
  1209. public List<QueryResult> searchFor(RepositoryModel repository, String text, int page, int pageSize) {
  1210. return indexer.searchFor(repository, text, page, pageSize);
  1211. }
  1212. /**
  1213. * Queries the index for the matching tickets.
  1214. *
  1215. * @param query
  1216. * @param page
  1217. * @param pageSize
  1218. * @param sortBy
  1219. * @param descending
  1220. * @return a list of matching tickets or an empty list
  1221. * @since 1.4.0
  1222. */
  1223. public List<QueryResult> queryFor(String query, int page, int pageSize, String sortBy, boolean descending) {
  1224. return indexer.queryFor(query, page, pageSize, sortBy, descending);
  1225. }
  1226. /**
  1227. * Checks tickets should get re-indexed.
  1228. *
  1229. * @return true if tickets should get re-indexed, false otherwise.
  1230. */
  1231. private boolean shouldReindex()
  1232. {
  1233. return indexer.shouldReindex();
  1234. }
  1235. /**
  1236. * Destroys an existing index and reindexes all tickets.
  1237. * This operation may be expensive and time-consuming.
  1238. * @since 1.4.0
  1239. */
  1240. public void reindex() {
  1241. long start = System.nanoTime();
  1242. indexer.deleteAll();
  1243. for (String name : repositoryManager.getRepositoryList()) {
  1244. RepositoryModel repository = repositoryManager.getRepositoryModel(name);
  1245. try {
  1246. List<TicketModel> tickets = getTickets(repository);
  1247. if (!tickets.isEmpty()) {
  1248. log.info("reindexing {} tickets from {} ...", tickets.size(), repository);
  1249. indexer.index(tickets);
  1250. System.gc();
  1251. }
  1252. } catch (Exception e) {
  1253. log.error("failed to reindex {}", repository.name);
  1254. log.error(null, e);
  1255. }
  1256. }
  1257. long end = System.nanoTime();
  1258. long secs = TimeUnit.NANOSECONDS.toMillis(end - start);
  1259. log.info("reindexing completed in {} msecs.", secs);
  1260. }
  1261. /**
  1262. * Destroys any existing index and reindexes all tickets.
  1263. * This operation may be expensive and time-consuming.
  1264. * @since 1.4.0
  1265. */
  1266. public void reindex(RepositoryModel repository) {
  1267. long start = System.nanoTime();
  1268. List<TicketModel> tickets = getTickets(repository);
  1269. indexer.index(tickets);
  1270. log.info("reindexing {} tickets from {} ...", tickets.size(), repository);
  1271. long end = System.nanoTime();
  1272. long secs = TimeUnit.NANOSECONDS.toMillis(end - start);
  1273. log.info("reindexing completed in {} msecs.", secs);
  1274. resetCaches(repository);
  1275. }
  1276. /**
  1277. * Synchronously executes the runnable. This is used for special processing
  1278. * of ticket updates, namely merging from the web ui.
  1279. *
  1280. * @param runnable
  1281. * @since 1.4.0
  1282. */
  1283. public synchronized void exec(Runnable runnable) {
  1284. runnable.run();
  1285. }
  1286. }