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.

TicketModel.java 28KB

Ticket tracker with patchset contributions A basic issue tracker styled as a hybrid of GitHub and BitBucket issues. You may attach commits to an existing ticket or you can push a single commit to create a *proposal* ticket. Tickets keep track of patchsets (one or more commits) and allow patchset rewriting (rebase, amend, squash) by detecing the non-fast-forward update and assigning a new patchset number to the new commits. Ticket tracker -------------- The ticket tracker stores tickets as an append-only journal of changes. The journals are deserialized and a ticket is built by applying the journal entries. Tickets are indexed using Apache Lucene and all queries and searches are executed against this Lucene index. There is one trade-off to this persistence design: user attributions are non-relational. What does that mean? Each journal entry stores the username of the author. If the username changes in the user service, the journal entry will not reflect that change because the values are hard-coded. Here are a few reasons/justifications for this design choice: 1. commit identifications (author, committer, tagger) are non-relational 2. maintains the KISS principle 3. your favorite text editor can still be your administration tool Persistence Choices ------------------- **FileTicketService**: stores journals on the filesystem **BranchTicketService**: stores journals on an orphan branch **RedisTicketService**: stores journals in a Redis key-value datastore It should be relatively straight-forward to develop other backends (MongoDB, etc) as long as the journal design is preserved. Pushing Commits --------------- Each push to a ticket is identified as a patchset revision. A patchset revision may add commits to the patchset (fast-forward) OR a patchset revision may rewrite history (rebase, squash, rebase+squash, or amend). Patchset authors should not be afraid to polish, revise, and rewrite their code before merging into the proposed branch. Gitblit will create one ref for each patchset. These refs are updated for fast-forward pushes or created for rewrites. They are formatted as `refs/tickets/{shard}/{id}/{patchset}`. The *shard* is the last two digits of the id. If the id < 10, prefix a 0. The *shard* is always two digits long. The shard's purpose is to ensure Gitblit doesn't exceed any filesystem directory limits for file creation. **Creating a Proposal Ticket** You may create a new change proposal ticket just by pushing a **single commit** to `refs/for/{branch}` where branch is the proposed integration branch OR `refs/for/new` or `refs/for/default` which both will use the default repository branch. git push origin HEAD:refs/for/new **Updating a Patchset** The safe way to update an existing patchset is to push to the patchset ref. git push origin HEAD:refs/heads/ticket/{id} This ensures you do not accidentally create a new patchset in the event that the patchset was updated after you last pulled. The not-so-safe way to update an existing patchset is to push using the magic ref. git push origin HEAD:refs/for/{id} This push ref will update an exisitng patchset OR create a new patchset if the update is non-fast-forward. **Rebasing, Squashing, Amending** Gitblit makes rebasing, squashing, and amending patchsets easy. Normally, pushing a non-fast-forward update would require rewind (RW+) repository permissions. Gitblit provides a magic ref which will allow ticket participants to rewrite a ticket patchset as long as the ticket is open. git push origin HEAD:refs/for/{id} Pushing changes to this ref allows the patchset authors to rebase, squash, or amend the patchset commits without requiring client-side use of the *--force* flag on push AND without requiring RW+ permission to the repository. Since each patchset is tracked with a ref it is easy to recover from accidental non-fast-forward updates. Features -------- - Ticket tracker with status changes and responsible assignments - Patchset revision scoring mechanism - Update/Rewrite patchset handling - Close-on-push detection - Server-side Merge button for simple merges - Comments with Markdown syntax support - Rich mail notifications - Voting - Mentions - Watch lists - Querying - Searches - Partial miletones support - Multiple backend options
10 years ago
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286
  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.models;
  17. import java.io.ByteArrayInputStream;
  18. import java.io.ByteArrayOutputStream;
  19. import java.io.IOException;
  20. import java.io.ObjectInputStream;
  21. import java.io.ObjectOutputStream;
  22. import java.io.Serializable;
  23. import java.io.UnsupportedEncodingException;
  24. import java.security.MessageDigest;
  25. import java.security.NoSuchAlgorithmException;
  26. import java.text.MessageFormat;
  27. import java.util.ArrayList;
  28. import java.util.Arrays;
  29. import java.util.Collection;
  30. import java.util.Collections;
  31. import java.util.Date;
  32. import java.util.HashMap;
  33. import java.util.LinkedHashMap;
  34. import java.util.LinkedHashSet;
  35. import java.util.List;
  36. import java.util.Map;
  37. import java.util.Set;
  38. import java.util.TreeSet;
  39. import java.util.regex.Matcher;
  40. import java.util.regex.Pattern;
  41. import org.eclipse.jgit.util.RelativeDateFormatter;
  42. /**
  43. * The Gitblit Ticket model, its component classes, and enums.
  44. *
  45. * @author James Moger
  46. *
  47. */
  48. public class TicketModel implements Serializable, Comparable<TicketModel> {
  49. private static final long serialVersionUID = 1L;
  50. public String project;
  51. public String repository;
  52. public long number;
  53. public Date created;
  54. public String createdBy;
  55. public Date updated;
  56. public String updatedBy;
  57. public String title;
  58. public String body;
  59. public String topic;
  60. public Type type;
  61. public Status status;
  62. public String responsible;
  63. public String milestone;
  64. public String mergeSha;
  65. public String mergeTo;
  66. public List<Change> changes;
  67. public Integer insertions;
  68. public Integer deletions;
  69. /**
  70. * Builds an effective ticket from the collection of changes. A change may
  71. * Add or Subtract information from a ticket, but the collection of changes
  72. * is only additive.
  73. *
  74. * @param changes
  75. * @return the effective ticket
  76. */
  77. public static TicketModel buildTicket(Collection<Change> changes) {
  78. TicketModel ticket;
  79. List<Change> effectiveChanges = new ArrayList<Change>();
  80. Map<String, Change> comments = new HashMap<String, Change>();
  81. for (Change change : changes) {
  82. if (change.comment != null) {
  83. if (comments.containsKey(change.comment.id)) {
  84. Change original = comments.get(change.comment.id);
  85. Change clone = copy(original);
  86. clone.comment.text = change.comment.text;
  87. clone.comment.deleted = change.comment.deleted;
  88. int idx = effectiveChanges.indexOf(original);
  89. effectiveChanges.remove(original);
  90. effectiveChanges.add(idx, clone);
  91. comments.put(clone.comment.id, clone);
  92. } else {
  93. effectiveChanges.add(change);
  94. comments.put(change.comment.id, change);
  95. }
  96. } else {
  97. effectiveChanges.add(change);
  98. }
  99. }
  100. // effective ticket
  101. ticket = new TicketModel();
  102. for (Change change : effectiveChanges) {
  103. if (!change.hasComment()) {
  104. // ensure we do not include a deleted comment
  105. change.comment = null;
  106. }
  107. ticket.applyChange(change);
  108. }
  109. return ticket;
  110. }
  111. public TicketModel() {
  112. // the first applied change set the date appropriately
  113. created = new Date(0);
  114. changes = new ArrayList<Change>();
  115. status = Status.New;
  116. type = Type.defaultType;
  117. }
  118. public boolean isOpen() {
  119. return !status.isClosed();
  120. }
  121. public boolean isClosed() {
  122. return status.isClosed();
  123. }
  124. public boolean isMerged() {
  125. return isClosed() && !isEmpty(mergeSha);
  126. }
  127. public boolean isProposal() {
  128. return Type.Proposal == type;
  129. }
  130. public boolean isBug() {
  131. return Type.Bug == type;
  132. }
  133. public Date getLastUpdated() {
  134. return updated == null ? created : updated;
  135. }
  136. public boolean hasPatchsets() {
  137. return getPatchsets().size() > 0;
  138. }
  139. /**
  140. * Returns true if multiple participants are involved in discussing a ticket.
  141. * The ticket creator is excluded from this determination because a
  142. * discussion requires more than one participant.
  143. *
  144. * @return true if this ticket has a discussion
  145. */
  146. public boolean hasDiscussion() {
  147. for (Change change : getComments()) {
  148. if (!change.author.equals(createdBy)) {
  149. return true;
  150. }
  151. }
  152. return false;
  153. }
  154. /**
  155. * Returns the list of changes with comments.
  156. *
  157. * @return
  158. */
  159. public List<Change> getComments() {
  160. List<Change> list = new ArrayList<Change>();
  161. for (Change change : changes) {
  162. if (change.hasComment()) {
  163. list.add(change);
  164. }
  165. }
  166. return list;
  167. }
  168. /**
  169. * Returns the list of participants for the ticket.
  170. *
  171. * @return the list of participants
  172. */
  173. public List<String> getParticipants() {
  174. Set<String> set = new LinkedHashSet<String>();
  175. for (Change change : changes) {
  176. if (change.isParticipantChange()) {
  177. set.add(change.author);
  178. }
  179. }
  180. if (responsible != null && responsible.length() > 0) {
  181. set.add(responsible);
  182. }
  183. return new ArrayList<String>(set);
  184. }
  185. public boolean hasLabel(String label) {
  186. return getLabels().contains(label);
  187. }
  188. public List<String> getLabels() {
  189. return getList(Field.labels);
  190. }
  191. public boolean isResponsible(String username) {
  192. return username.equals(responsible);
  193. }
  194. public boolean isAuthor(String username) {
  195. return username.equals(createdBy);
  196. }
  197. public boolean isReviewer(String username) {
  198. return getReviewers().contains(username);
  199. }
  200. public List<String> getReviewers() {
  201. return getList(Field.reviewers);
  202. }
  203. public boolean isWatching(String username) {
  204. return getWatchers().contains(username);
  205. }
  206. public List<String> getWatchers() {
  207. return getList(Field.watchers);
  208. }
  209. public boolean isVoter(String username) {
  210. return getVoters().contains(username);
  211. }
  212. public List<String> getVoters() {
  213. return getList(Field.voters);
  214. }
  215. public List<String> getMentions() {
  216. return getList(Field.mentions);
  217. }
  218. protected List<String> getList(Field field) {
  219. Set<String> set = new TreeSet<String>();
  220. for (Change change : changes) {
  221. if (change.hasField(field)) {
  222. String values = change.getString(field);
  223. for (String value : values.split(",")) {
  224. switch (value.charAt(0)) {
  225. case '+':
  226. set.add(value.substring(1));
  227. break;
  228. case '-':
  229. set.remove(value.substring(1));
  230. break;
  231. default:
  232. set.add(value);
  233. }
  234. }
  235. }
  236. }
  237. if (!set.isEmpty()) {
  238. return new ArrayList<String>(set);
  239. }
  240. return Collections.emptyList();
  241. }
  242. public Attachment getAttachment(String name) {
  243. Attachment attachment = null;
  244. for (Change change : changes) {
  245. if (change.hasAttachments()) {
  246. Attachment a = change.getAttachment(name);
  247. if (a != null) {
  248. attachment = a;
  249. }
  250. }
  251. }
  252. return attachment;
  253. }
  254. public boolean hasAttachments() {
  255. for (Change change : changes) {
  256. if (change.hasAttachments()) {
  257. return true;
  258. }
  259. }
  260. return false;
  261. }
  262. public List<Attachment> getAttachments() {
  263. List<Attachment> list = new ArrayList<Attachment>();
  264. for (Change change : changes) {
  265. if (change.hasAttachments()) {
  266. list.addAll(change.attachments);
  267. }
  268. }
  269. return list;
  270. }
  271. public List<Patchset> getPatchsets() {
  272. List<Patchset> list = new ArrayList<Patchset>();
  273. for (Change change : changes) {
  274. if (change.patchset != null) {
  275. list.add(change.patchset);
  276. }
  277. }
  278. return list;
  279. }
  280. public List<Patchset> getPatchsetRevisions(int number) {
  281. List<Patchset> list = new ArrayList<Patchset>();
  282. for (Change change : changes) {
  283. if (change.patchset != null) {
  284. if (number == change.patchset.number) {
  285. list.add(change.patchset);
  286. }
  287. }
  288. }
  289. return list;
  290. }
  291. public Patchset getPatchset(String sha) {
  292. for (Change change : changes) {
  293. if (change.patchset != null) {
  294. if (sha.equals(change.patchset.tip)) {
  295. return change.patchset;
  296. }
  297. }
  298. }
  299. return null;
  300. }
  301. public Patchset getPatchset(int number, int rev) {
  302. for (Change change : changes) {
  303. if (change.patchset != null) {
  304. if (number == change.patchset.number && rev == change.patchset.rev) {
  305. return change.patchset;
  306. }
  307. }
  308. }
  309. return null;
  310. }
  311. public Patchset getCurrentPatchset() {
  312. Patchset patchset = null;
  313. for (Change change : changes) {
  314. if (change.patchset != null) {
  315. if (patchset == null) {
  316. patchset = change.patchset;
  317. } else if (patchset.compareTo(change.patchset) == 1) {
  318. patchset = change.patchset;
  319. }
  320. }
  321. }
  322. return patchset;
  323. }
  324. public boolean isCurrent(Patchset patchset) {
  325. if (patchset == null) {
  326. return false;
  327. }
  328. Patchset curr = getCurrentPatchset();
  329. if (curr == null) {
  330. return false;
  331. }
  332. return curr.equals(patchset);
  333. }
  334. public List<Change> getReviews(Patchset patchset) {
  335. if (patchset == null) {
  336. return Collections.emptyList();
  337. }
  338. // collect the patchset reviews by author
  339. // the last review by the author is the
  340. // official review
  341. Map<String, Change> reviews = new LinkedHashMap<String, TicketModel.Change>();
  342. for (Change change : changes) {
  343. if (change.hasReview()) {
  344. if (change.review.isReviewOf(patchset)) {
  345. reviews.put(change.author, change);
  346. }
  347. }
  348. }
  349. return new ArrayList<Change>(reviews.values());
  350. }
  351. public boolean isApproved(Patchset patchset) {
  352. if (patchset == null) {
  353. return false;
  354. }
  355. boolean approved = false;
  356. boolean vetoed = false;
  357. for (Change change : getReviews(patchset)) {
  358. if (change.hasReview()) {
  359. if (change.review.isReviewOf(patchset)) {
  360. if (Score.approved == change.review.score) {
  361. approved = true;
  362. } else if (Score.vetoed == change.review.score) {
  363. vetoed = true;
  364. }
  365. }
  366. }
  367. }
  368. return approved && !vetoed;
  369. }
  370. public boolean isVetoed(Patchset patchset) {
  371. if (patchset == null) {
  372. return false;
  373. }
  374. for (Change change : getReviews(patchset)) {
  375. if (change.hasReview()) {
  376. if (change.review.isReviewOf(patchset)) {
  377. if (Score.vetoed == change.review.score) {
  378. return true;
  379. }
  380. }
  381. }
  382. }
  383. return false;
  384. }
  385. public Review getReviewBy(String username) {
  386. for (Change change : getReviews(getCurrentPatchset())) {
  387. if (change.author.equals(username)) {
  388. return change.review;
  389. }
  390. }
  391. return null;
  392. }
  393. public boolean isPatchsetAuthor(String username) {
  394. for (Change change : changes) {
  395. if (change.hasPatchset()) {
  396. if (change.author.equals(username)) {
  397. return true;
  398. }
  399. }
  400. }
  401. return false;
  402. }
  403. public void applyChange(Change change) {
  404. if (changes.size() == 0) {
  405. // first change created the ticket
  406. created = change.date;
  407. createdBy = change.author;
  408. status = Status.New;
  409. } else if (created == null || change.date.after(created)) {
  410. // track last ticket update
  411. updated = change.date;
  412. updatedBy = change.author;
  413. }
  414. if (change.isMerge()) {
  415. // identify merge patchsets
  416. if (isEmpty(responsible)) {
  417. responsible = change.author;
  418. }
  419. status = Status.Merged;
  420. }
  421. if (change.hasFieldChanges()) {
  422. for (Map.Entry<Field, String> entry : change.fields.entrySet()) {
  423. Field field = entry.getKey();
  424. Object value = entry.getValue();
  425. switch (field) {
  426. case type:
  427. type = TicketModel.Type.fromObject(value, type);
  428. break;
  429. case status:
  430. status = TicketModel.Status.fromObject(value, status);
  431. break;
  432. case title:
  433. title = toString(value);
  434. break;
  435. case body:
  436. body = toString(value);
  437. break;
  438. case topic:
  439. topic = toString(value);
  440. break;
  441. case responsible:
  442. responsible = toString(value);
  443. break;
  444. case milestone:
  445. milestone = toString(value);
  446. break;
  447. case mergeTo:
  448. mergeTo = toString(value);
  449. break;
  450. case mergeSha:
  451. mergeSha = toString(value);
  452. break;
  453. default:
  454. // unknown
  455. break;
  456. }
  457. }
  458. }
  459. // add the change to the ticket
  460. changes.add(change);
  461. }
  462. protected String toString(Object value) {
  463. if (value == null) {
  464. return null;
  465. }
  466. return value.toString();
  467. }
  468. public String toIndexableString() {
  469. StringBuilder sb = new StringBuilder();
  470. if (!isEmpty(title)) {
  471. sb.append(title).append('\n');
  472. }
  473. if (!isEmpty(body)) {
  474. sb.append(body).append('\n');
  475. }
  476. for (Change change : changes) {
  477. if (change.hasComment()) {
  478. sb.append(change.comment.text);
  479. sb.append('\n');
  480. }
  481. }
  482. return sb.toString();
  483. }
  484. @Override
  485. public String toString() {
  486. StringBuilder sb = new StringBuilder();
  487. sb.append("#");
  488. sb.append(number);
  489. sb.append(": " + title + "\n");
  490. for (Change change : changes) {
  491. sb.append(change);
  492. sb.append('\n');
  493. }
  494. return sb.toString();
  495. }
  496. @Override
  497. public int compareTo(TicketModel o) {
  498. return o.created.compareTo(created);
  499. }
  500. @Override
  501. public boolean equals(Object o) {
  502. if (o instanceof TicketModel) {
  503. return number == ((TicketModel) o).number;
  504. }
  505. return super.equals(o);
  506. }
  507. @Override
  508. public int hashCode() {
  509. return (repository + number).hashCode();
  510. }
  511. /**
  512. * Encapsulates a ticket change
  513. */
  514. public static class Change implements Serializable, Comparable<Change> {
  515. private static final long serialVersionUID = 1L;
  516. public final Date date;
  517. public final String author;
  518. public Comment comment;
  519. public Map<Field, String> fields;
  520. public Set<Attachment> attachments;
  521. public Patchset patchset;
  522. public Review review;
  523. private transient String id;
  524. public Change(String author) {
  525. this(author, new Date());
  526. }
  527. public Change(String author, Date date) {
  528. this.date = date;
  529. this.author = author;
  530. }
  531. public boolean isStatusChange() {
  532. return hasField(Field.status);
  533. }
  534. public Status getStatus() {
  535. Status state = Status.fromObject(getField(Field.status), null);
  536. return state;
  537. }
  538. public boolean isMerge() {
  539. return hasField(Field.status) && hasField(Field.mergeSha);
  540. }
  541. public boolean hasPatchset() {
  542. return patchset != null;
  543. }
  544. public boolean hasReview() {
  545. return review != null;
  546. }
  547. public boolean hasComment() {
  548. return comment != null && !comment.isDeleted();
  549. }
  550. public Comment comment(String text) {
  551. comment = new Comment(text);
  552. comment.id = TicketModel.getSHA1(date.toString() + author + text);
  553. try {
  554. Pattern mentions = Pattern.compile("\\s@([A-Za-z0-9-_]+)");
  555. Matcher m = mentions.matcher(text);
  556. while (m.find()) {
  557. String username = m.group(1);
  558. plusList(Field.mentions, username);
  559. }
  560. } catch (Exception e) {
  561. // ignore
  562. }
  563. return comment;
  564. }
  565. public Review review(Patchset patchset, Score score, boolean addReviewer) {
  566. if (addReviewer) {
  567. plusList(Field.reviewers, author);
  568. }
  569. review = new Review(patchset.number, patchset.rev);
  570. review.score = score;
  571. return review;
  572. }
  573. public boolean hasAttachments() {
  574. return !TicketModel.isEmpty(attachments);
  575. }
  576. public void addAttachment(Attachment attachment) {
  577. if (attachments == null) {
  578. attachments = new LinkedHashSet<Attachment>();
  579. }
  580. attachments.add(attachment);
  581. }
  582. public Attachment getAttachment(String name) {
  583. if (attachments != null) {
  584. for (Attachment attachment : attachments) {
  585. if (attachment.name.equalsIgnoreCase(name)) {
  586. return attachment;
  587. }
  588. }
  589. }
  590. return null;
  591. }
  592. public boolean isParticipantChange() {
  593. if (hasComment()
  594. || hasReview()
  595. || hasPatchset()
  596. || hasAttachments()) {
  597. return true;
  598. }
  599. if (TicketModel.isEmpty(fields)) {
  600. return false;
  601. }
  602. // identify real ticket field changes
  603. Map<Field, String> map = new HashMap<Field, String>(fields);
  604. map.remove(Field.watchers);
  605. map.remove(Field.voters);
  606. return !map.isEmpty();
  607. }
  608. public boolean hasField(Field field) {
  609. return !TicketModel.isEmpty(getString(field));
  610. }
  611. public boolean hasFieldChanges() {
  612. return !TicketModel.isEmpty(fields);
  613. }
  614. public String getField(Field field) {
  615. if (fields != null) {
  616. return fields.get(field);
  617. }
  618. return null;
  619. }
  620. public void setField(Field field, Object value) {
  621. if (fields == null) {
  622. fields = new LinkedHashMap<Field, String>();
  623. }
  624. if (value == null) {
  625. fields.put(field, null);
  626. } else if (Enum.class.isAssignableFrom(value.getClass())) {
  627. fields.put(field, ((Enum<?>) value).name());
  628. } else {
  629. fields.put(field, value.toString());
  630. }
  631. }
  632. public void remove(Field field) {
  633. if (fields != null) {
  634. fields.remove(field);
  635. }
  636. }
  637. public String getString(Field field) {
  638. String value = getField(field);
  639. if (value == null) {
  640. return null;
  641. }
  642. return value;
  643. }
  644. public void watch(String... username) {
  645. plusList(Field.watchers, username);
  646. }
  647. public void unwatch(String... username) {
  648. minusList(Field.watchers, username);
  649. }
  650. public void vote(String... username) {
  651. plusList(Field.voters, username);
  652. }
  653. public void unvote(String... username) {
  654. minusList(Field.voters, username);
  655. }
  656. public void label(String... label) {
  657. plusList(Field.labels, label);
  658. }
  659. public void unlabel(String... label) {
  660. minusList(Field.labels, label);
  661. }
  662. protected void plusList(Field field, String... items) {
  663. modList(field, "+", items);
  664. }
  665. protected void minusList(Field field, String... items) {
  666. modList(field, "-", items);
  667. }
  668. private void modList(Field field, String prefix, String... items) {
  669. List<String> list = new ArrayList<String>();
  670. for (String item : items) {
  671. list.add(prefix + item);
  672. }
  673. setField(field, join(list, ","));
  674. }
  675. public String getId() {
  676. if (id == null) {
  677. id = getSHA1(Long.toHexString(date.getTime()) + author);
  678. }
  679. return id;
  680. }
  681. @Override
  682. public int compareTo(Change c) {
  683. return date.compareTo(c.date);
  684. }
  685. @Override
  686. public int hashCode() {
  687. return getId().hashCode();
  688. }
  689. @Override
  690. public boolean equals(Object o) {
  691. if (o instanceof Change) {
  692. return getId().equals(((Change) o).getId());
  693. }
  694. return false;
  695. }
  696. @Override
  697. public String toString() {
  698. StringBuilder sb = new StringBuilder();
  699. sb.append(RelativeDateFormatter.format(date));
  700. if (hasComment()) {
  701. sb.append(" commented on by ");
  702. } else if (hasPatchset()) {
  703. sb.append(MessageFormat.format(" {0} uploaded by ", patchset));
  704. } else {
  705. sb.append(" changed by ");
  706. }
  707. sb.append(author).append(" - ");
  708. if (hasComment()) {
  709. if (comment.isDeleted()) {
  710. sb.append("(deleted) ");
  711. }
  712. sb.append(comment.text).append(" ");
  713. }
  714. if (hasFieldChanges()) {
  715. for (Map.Entry<Field, String> entry : fields.entrySet()) {
  716. sb.append("\n ");
  717. sb.append(entry.getKey().name());
  718. sb.append(':');
  719. sb.append(entry.getValue());
  720. }
  721. }
  722. return sb.toString();
  723. }
  724. }
  725. /**
  726. * Returns true if the string is null or empty.
  727. *
  728. * @param value
  729. * @return true if string is null or empty
  730. */
  731. static boolean isEmpty(String value) {
  732. return value == null || value.trim().length() == 0;
  733. }
  734. /**
  735. * Returns true if the collection is null or empty
  736. *
  737. * @param collection
  738. * @return
  739. */
  740. static boolean isEmpty(Collection<?> collection) {
  741. return collection == null || collection.size() == 0;
  742. }
  743. /**
  744. * Returns true if the map is null or empty
  745. *
  746. * @param map
  747. * @return
  748. */
  749. static boolean isEmpty(Map<?, ?> map) {
  750. return map == null || map.size() == 0;
  751. }
  752. /**
  753. * Calculates the SHA1 of the string.
  754. *
  755. * @param text
  756. * @return sha1 of the string
  757. */
  758. static String getSHA1(String text) {
  759. try {
  760. byte[] bytes = text.getBytes("iso-8859-1");
  761. return getSHA1(bytes);
  762. } catch (UnsupportedEncodingException u) {
  763. throw new RuntimeException(u);
  764. }
  765. }
  766. /**
  767. * Calculates the SHA1 of the byte array.
  768. *
  769. * @param bytes
  770. * @return sha1 of the byte array
  771. */
  772. static String getSHA1(byte[] bytes) {
  773. try {
  774. MessageDigest md = MessageDigest.getInstance("SHA-1");
  775. md.update(bytes, 0, bytes.length);
  776. byte[] digest = md.digest();
  777. return toHex(digest);
  778. } catch (NoSuchAlgorithmException t) {
  779. throw new RuntimeException(t);
  780. }
  781. }
  782. /**
  783. * Returns the hex representation of the byte array.
  784. *
  785. * @param bytes
  786. * @return byte array as hex string
  787. */
  788. static String toHex(byte[] bytes) {
  789. StringBuilder sb = new StringBuilder(bytes.length * 2);
  790. for (int i = 0; i < bytes.length; i++) {
  791. if ((bytes[i] & 0xff) < 0x10) {
  792. sb.append('0');
  793. }
  794. sb.append(Long.toString(bytes[i] & 0xff, 16));
  795. }
  796. return sb.toString();
  797. }
  798. /**
  799. * Join the list of strings into a single string with a space separator.
  800. *
  801. * @param values
  802. * @return joined list
  803. */
  804. static String join(Collection<String> values) {
  805. return join(values, " ");
  806. }
  807. /**
  808. * Join the list of strings into a single string with the specified
  809. * separator.
  810. *
  811. * @param values
  812. * @param separator
  813. * @return joined list
  814. */
  815. static String join(String[] values, String separator) {
  816. return join(Arrays.asList(values), separator);
  817. }
  818. /**
  819. * Join the list of strings into a single string with the specified
  820. * separator.
  821. *
  822. * @param values
  823. * @param separator
  824. * @return joined list
  825. */
  826. static String join(Collection<String> values, String separator) {
  827. StringBuilder sb = new StringBuilder();
  828. for (String value : values) {
  829. sb.append(value).append(separator);
  830. }
  831. if (sb.length() > 0) {
  832. // truncate trailing separator
  833. sb.setLength(sb.length() - separator.length());
  834. }
  835. return sb.toString().trim();
  836. }
  837. /**
  838. * Produce a deep copy of the given object. Serializes the entire object to
  839. * a byte array in memory. Recommended for relatively small objects.
  840. */
  841. @SuppressWarnings("unchecked")
  842. static <T> T copy(T original) {
  843. T o = null;
  844. try {
  845. ByteArrayOutputStream byteOut = new ByteArrayOutputStream();
  846. ObjectOutputStream oos = new ObjectOutputStream(byteOut);
  847. oos.writeObject(original);
  848. ByteArrayInputStream byteIn = new ByteArrayInputStream(byteOut.toByteArray());
  849. ObjectInputStream ois = new ObjectInputStream(byteIn);
  850. try {
  851. o = (T) ois.readObject();
  852. } catch (ClassNotFoundException cex) {
  853. // actually can not happen in this instance
  854. }
  855. } catch (IOException iox) {
  856. // doesn't seem likely to happen as these streams are in memory
  857. throw new RuntimeException(iox);
  858. }
  859. return o;
  860. }
  861. public static class Patchset implements Serializable, Comparable<Patchset> {
  862. private static final long serialVersionUID = 1L;
  863. public int number;
  864. public int rev;
  865. public String tip;
  866. public String parent;
  867. public String base;
  868. public int insertions;
  869. public int deletions;
  870. public int commits;
  871. public int added;
  872. public PatchsetType type;
  873. public boolean isFF() {
  874. return PatchsetType.FastForward == type;
  875. }
  876. @Override
  877. public int hashCode() {
  878. return toString().hashCode();
  879. }
  880. @Override
  881. public boolean equals(Object o) {
  882. if (o instanceof Patchset) {
  883. return hashCode() == o.hashCode();
  884. }
  885. return false;
  886. }
  887. @Override
  888. public int compareTo(Patchset p) {
  889. if (number > p.number) {
  890. return -1;
  891. } else if (p.number > number) {
  892. return 1;
  893. } else {
  894. // same patchset, different revision
  895. if (rev > p.rev) {
  896. return -1;
  897. } else if (p.rev > rev) {
  898. return 1;
  899. } else {
  900. // same patchset & revision
  901. return 0;
  902. }
  903. }
  904. }
  905. @Override
  906. public String toString() {
  907. return "patchset " + number + " revision " + rev;
  908. }
  909. }
  910. public static class Comment implements Serializable {
  911. private static final long serialVersionUID = 1L;
  912. public String text;
  913. public String id;
  914. public Boolean deleted;
  915. public CommentSource src;
  916. public String replyTo;
  917. Comment(String text) {
  918. this.text = text;
  919. }
  920. public boolean isDeleted() {
  921. return deleted != null && deleted;
  922. }
  923. @Override
  924. public String toString() {
  925. return text;
  926. }
  927. }
  928. public static class Attachment implements Serializable {
  929. private static final long serialVersionUID = 1L;
  930. public final String name;
  931. public long size;
  932. public byte[] content;
  933. public Boolean deleted;
  934. public Attachment(String name) {
  935. this.name = name;
  936. }
  937. public boolean isDeleted() {
  938. return deleted != null && deleted;
  939. }
  940. @Override
  941. public int hashCode() {
  942. return name.hashCode();
  943. }
  944. @Override
  945. public boolean equals(Object o) {
  946. if (o instanceof Attachment) {
  947. return name.equalsIgnoreCase(((Attachment) o).name);
  948. }
  949. return false;
  950. }
  951. @Override
  952. public String toString() {
  953. return name;
  954. }
  955. }
  956. public static class Review implements Serializable {
  957. private static final long serialVersionUID = 1L;
  958. public final int patchset;
  959. public final int rev;
  960. public Score score;
  961. public Review(int patchset, int revision) {
  962. this.patchset = patchset;
  963. this.rev = revision;
  964. }
  965. public boolean isReviewOf(Patchset p) {
  966. return patchset == p.number && rev == p.rev;
  967. }
  968. @Override
  969. public String toString() {
  970. return "review of patchset " + patchset + " rev " + rev + ":" + score;
  971. }
  972. }
  973. public static enum Score {
  974. approved(2), looks_good(1), not_reviewed(0), needs_improvement(-1), vetoed(-2);
  975. final int value;
  976. Score(int value) {
  977. this.value = value;
  978. }
  979. public int getValue() {
  980. return value;
  981. }
  982. @Override
  983. public String toString() {
  984. return name().toLowerCase().replace('_', ' ');
  985. }
  986. }
  987. public static enum Field {
  988. title, body, responsible, type, status, milestone, mergeSha, mergeTo,
  989. topic, labels, watchers, reviewers, voters, mentions;
  990. }
  991. public static enum Type {
  992. Enhancement, Task, Bug, Proposal, Question;
  993. public static Type defaultType = Task;
  994. public static Type [] choices() {
  995. return new Type [] { Enhancement, Task, Bug, Question };
  996. }
  997. @Override
  998. public String toString() {
  999. return name().toLowerCase().replace('_', ' ');
  1000. }
  1001. public static Type fromObject(Object o, Type defaultType) {
  1002. if (o instanceof Type) {
  1003. // cast and return
  1004. return (Type) o;
  1005. } else if (o instanceof String) {
  1006. // find by name
  1007. for (Type type : values()) {
  1008. String str = o.toString();
  1009. if (type.name().equalsIgnoreCase(str)
  1010. || type.toString().equalsIgnoreCase(str)) {
  1011. return type;
  1012. }
  1013. }
  1014. } else if (o instanceof Number) {
  1015. // by ordinal
  1016. int id = ((Number) o).intValue();
  1017. if (id >= 0 && id < values().length) {
  1018. return values()[id];
  1019. }
  1020. }
  1021. return defaultType;
  1022. }
  1023. }
  1024. public static enum Status {
  1025. New, Open, Resolved, Fixed, Merged, Wontfix, Declined, Duplicate, Invalid, On_Hold;
  1026. public static Status [] requestWorkflow = { Open, Resolved, Declined, Duplicate, Invalid, On_Hold };
  1027. public static Status [] bugWorkflow = { Open, Fixed, Wontfix, Duplicate, Invalid, On_Hold };
  1028. public static Status [] proposalWorkflow = { Open, Declined, On_Hold};
  1029. @Override
  1030. public String toString() {
  1031. return name().toLowerCase().replace('_', ' ');
  1032. }
  1033. public static Status fromObject(Object o, Status defaultStatus) {
  1034. if (o instanceof Status) {
  1035. // cast and return
  1036. return (Status) o;
  1037. } else if (o instanceof String) {
  1038. // find by name
  1039. String name = o.toString();
  1040. for (Status state : values()) {
  1041. if (state.name().equalsIgnoreCase(name)
  1042. || state.toString().equalsIgnoreCase(name)) {
  1043. return state;
  1044. }
  1045. }
  1046. } else if (o instanceof Number) {
  1047. // by ordinal
  1048. int id = ((Number) o).intValue();
  1049. if (id >= 0 && id < values().length) {
  1050. return values()[id];
  1051. }
  1052. }
  1053. return defaultStatus;
  1054. }
  1055. public boolean isClosed() {
  1056. return ordinal() > Open.ordinal();
  1057. }
  1058. }
  1059. public static enum CommentSource {
  1060. Comment, Email
  1061. }
  1062. public static enum PatchsetType {
  1063. Proposal, FastForward, Rebase, Squash, Rebase_Squash, Amend;
  1064. public boolean isRewrite() {
  1065. return (this != FastForward) && (this != Proposal);
  1066. }
  1067. @Override
  1068. public String toString() {
  1069. return name().toLowerCase().replace('_', '+');
  1070. }
  1071. public static PatchsetType fromObject(Object o) {
  1072. if (o instanceof PatchsetType) {
  1073. // cast and return
  1074. return (PatchsetType) o;
  1075. } else if (o instanceof String) {
  1076. // find by name
  1077. String name = o.toString();
  1078. for (PatchsetType type : values()) {
  1079. if (type.name().equalsIgnoreCase(name)
  1080. || type.toString().equalsIgnoreCase(name)) {
  1081. return type;
  1082. }
  1083. }
  1084. } else if (o instanceof Number) {
  1085. // by ordinal
  1086. int id = ((Number) o).intValue();
  1087. if (id >= 0 && id < values().length) {
  1088. return values()[id];
  1089. }
  1090. }
  1091. return null;
  1092. }
  1093. }
  1094. }