]> source.dussan.org Git - gitblit.git/commitdiff
Ticket tracker with patchset contributions 01/1/18
authorJames Moger <james.moger@gitblit.com>
Mon, 9 Dec 2013 22:19:03 +0000 (17:19 -0500)
committerJames Moger <james.moger@gitblit.com>
Tue, 4 Mar 2014 02:34:32 +0000 (21:34 -0500)
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

89 files changed:
.classpath
NOTICE
build.moxie
build.xml
gitblit.iml
releases.moxie
src/main/distrib/data/clientapps.json
src/main/distrib/data/gitblit.properties
src/main/distrib/linux/reindex-tickets.sh [new file with mode: 0644]
src/main/distrib/win/reindex-tickets.cmd [new file with mode: 0644]
src/main/java/WEB-INF/web.xml
src/main/java/com/gitblit/Constants.java
src/main/java/com/gitblit/GitBlit.java
src/main/java/com/gitblit/ReindexTickets.java [new file with mode: 0644]
src/main/java/com/gitblit/client/EditRepositoryDialog.java
src/main/java/com/gitblit/git/GitblitReceivePack.java
src/main/java/com/gitblit/git/GitblitReceivePackFactory.java
src/main/java/com/gitblit/git/PatchsetCommand.java [new file with mode: 0644]
src/main/java/com/gitblit/git/PatchsetReceivePack.java [new file with mode: 0644]
src/main/java/com/gitblit/manager/GitblitManager.java
src/main/java/com/gitblit/manager/IGitblit.java
src/main/java/com/gitblit/manager/RepositoryManager.java
src/main/java/com/gitblit/models/RepositoryModel.java
src/main/java/com/gitblit/models/TicketModel.java [new file with mode: 0644]
src/main/java/com/gitblit/models/UserModel.java
src/main/java/com/gitblit/servlet/PtServlet.java [new file with mode: 0644]
src/main/java/com/gitblit/tickets/BranchTicketService.java [new file with mode: 0644]
src/main/java/com/gitblit/tickets/FileTicketService.java [new file with mode: 0644]
src/main/java/com/gitblit/tickets/ITicketService.java [new file with mode: 0644]
src/main/java/com/gitblit/tickets/NullTicketService.java [new file with mode: 0644]
src/main/java/com/gitblit/tickets/QueryBuilder.java [new file with mode: 0644]
src/main/java/com/gitblit/tickets/QueryResult.java [new file with mode: 0644]
src/main/java/com/gitblit/tickets/RedisTicketService.java [new file with mode: 0644]
src/main/java/com/gitblit/tickets/TicketIndexer.java [new file with mode: 0644]
src/main/java/com/gitblit/tickets/TicketLabel.java [new file with mode: 0644]
src/main/java/com/gitblit/tickets/TicketMilestone.java [new file with mode: 0644]
src/main/java/com/gitblit/tickets/TicketNotifier.java [new file with mode: 0644]
src/main/java/com/gitblit/tickets/TicketResponsible.java [new file with mode: 0644]
src/main/java/com/gitblit/tickets/TicketSerializer.java [new file with mode: 0644]
src/main/java/com/gitblit/tickets/commands.md [new file with mode: 0644]
src/main/java/com/gitblit/tickets/email.css [new file with mode: 0644]
src/main/java/com/gitblit/utils/JGitUtils.java
src/main/java/com/gitblit/utils/JsonUtils.java
src/main/java/com/gitblit/utils/MarkdownUtils.java
src/main/java/com/gitblit/utils/RefLogUtils.java
src/main/java/com/gitblit/wicket/GitBlitWebApp.java
src/main/java/com/gitblit/wicket/GitBlitWebApp.properties
src/main/java/com/gitblit/wicket/pages/BasePage.java
src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.html
src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.java
src/main/java/com/gitblit/wicket/pages/EditTicketPage.html [new file with mode: 0644]
src/main/java/com/gitblit/wicket/pages/EditTicketPage.java [new file with mode: 0644]
src/main/java/com/gitblit/wicket/pages/ExportTicketPage.java [new file with mode: 0644]
src/main/java/com/gitblit/wicket/pages/NewTicketPage.html [new file with mode: 0644]
src/main/java/com/gitblit/wicket/pages/NewTicketPage.java [new file with mode: 0644]
src/main/java/com/gitblit/wicket/pages/NoTicketsPage.html [new file with mode: 0644]
src/main/java/com/gitblit/wicket/pages/NoTicketsPage.java [new file with mode: 0644]
src/main/java/com/gitblit/wicket/pages/RepositoryPage.html
src/main/java/com/gitblit/wicket/pages/RepositoryPage.java
src/main/java/com/gitblit/wicket/pages/TicketBasePage.java [new file with mode: 0644]
src/main/java/com/gitblit/wicket/pages/TicketPage.html [new file with mode: 0644]
src/main/java/com/gitblit/wicket/pages/TicketPage.java [new file with mode: 0644]
src/main/java/com/gitblit/wicket/pages/TicketsPage.html [new file with mode: 0644]
src/main/java/com/gitblit/wicket/pages/TicketsPage.java [new file with mode: 0644]
src/main/java/com/gitblit/wicket/pages/propose_git.md [new file with mode: 0644]
src/main/java/com/gitblit/wicket/pages/propose_pt.md [new file with mode: 0644]
src/main/java/com/gitblit/wicket/panels/CommentPanel.html [new file with mode: 0644]
src/main/java/com/gitblit/wicket/panels/CommentPanel.java [new file with mode: 0644]
src/main/java/com/gitblit/wicket/panels/DigestsPanel.java
src/main/java/com/gitblit/wicket/panels/GravatarImage.java
src/main/java/com/gitblit/wicket/panels/MarkdownTextArea.java [new file with mode: 0644]
src/main/java/com/gitblit/wicket/panels/ReflogPanel.html
src/main/java/com/gitblit/wicket/panels/ReflogPanel.java
src/main/java/com/gitblit/wicket/panels/RefsPanel.java
src/main/java/pt.cmd [new file with mode: 0644]
src/main/java/pt.py [new file with mode: 0644]
src/main/java/pt.txt [new file with mode: 0644]
src/main/resources/barnum_32x32.png [new file with mode: 0644]
src/main/resources/gitblit.css
src/site/design.mkd
src/site/tickets_barnum.mkd [new file with mode: 0644]
src/site/tickets_overview.mkd [new file with mode: 0644]
src/site/tickets_setup.mkd [new file with mode: 0644]
src/site/tickets_using.mkd [new file with mode: 0644]
src/test/java/com/gitblit/tests/BranchTicketServiceTest.java [new file with mode: 0644]
src/test/java/com/gitblit/tests/FileTicketServiceTest.java [new file with mode: 0644]
src/test/java/com/gitblit/tests/GitBlitSuite.java
src/test/java/com/gitblit/tests/RedisTicketServiceTest.java [new file with mode: 0644]
src/test/java/com/gitblit/tests/TicketServiceTest.java [new file with mode: 0644]

index 500283ef585d0e30913ce6366b4578a28909814c..462ac8c3967458ce0cde863973e60f5469b41d8b 100644 (file)
@@ -70,6 +70,8 @@
        <classpathentry kind="lib" path="ext/guava-13.0.1.jar" sourcepath="ext/src/guava-13.0.1.jar" />
        <classpathentry kind="lib" path="ext/libpam4j-1.7.jar" sourcepath="ext/src/libpam4j-1.7.jar" />
        <classpathentry kind="lib" path="ext/commons-codec-1.7.jar" sourcepath="ext/src/commons-codec-1.7.jar" />
+       <classpathentry kind="lib" path="ext/jedis-2.3.1.jar" sourcepath="ext/src/jedis-2.3.1.jar" />
+       <classpathentry kind="lib" path="ext/commons-pool2-2.0.jar" sourcepath="ext/src/commons-pool2-2.0.jar" />
        <classpathentry kind="lib" path="ext/junit-4.11.jar" sourcepath="ext/src/junit-4.11.jar" />
        <classpathentry kind="lib" path="ext/hamcrest-core-1.3.jar" sourcepath="ext/src/hamcrest-core-1.3.jar" />
        <classpathentry kind="lib" path="ext/selenium-java-2.28.0.jar" sourcepath="ext/src/selenium-java-2.28.0.jar" />
diff --git a/NOTICE b/NOTICE
index 29c28aaaad9fb0c8185aeef07a1a6c2d8adb1ca3..1417ecaf860356a64e0a8ee3c5e42b27d9173430 100644 (file)
--- a/NOTICE
+++ b/NOTICE
@@ -326,4 +326,19 @@ font-awesome
    SIL OFL 1.1.\r
    \r
    https://github.com/FortAwesome/Font-Awesome\r
-        
\ No newline at end of file
+\r
+---------------------------------------------------------------------------\r
+AUI (excerpts)\r
+---------------------------------------------------------------------------\r
+   AUI, release under the\r
+   Apache License 2.0\r
+   \r
+   https://bitbucket.org/atlassian/aui\r
+\r
+---------------------------------------------------------------------------\r
+Jedis\r
+---------------------------------------------------------------------------\r
+   Jedis, release under the\r
+   MIT license\r
+   \r
+   https://github.com/xetorthio/jedis\r
index 697e05476ab0140ba18b97ed906c23a61b9e63da..02066b4c205ee2f9c98c39383151b8778444fc97 100644 (file)
@@ -170,6 +170,7 @@ dependencies:
 - compile 'com.github.dblock.waffle:waffle-jna:1.5' :war
 - compile 'org.kohsuke:libpam4j:1.7' :war
 - compile 'commons-codec:commons-codec:1.7' :war
+- compile 'redis.clients:jedis:2.3.1' :war
 - test 'junit'
 # Dependencies for Selenium web page testing
 - test 'org.seleniumhq.selenium:selenium-java:${selenium.version}' @jar
index 69b30e288ce4d8b70cbcf6f8812de50a0d55aa65..9f77610611d003b2caf1807ba6d11662dfa09966 100644 (file)
--- a/build.xml
+++ b/build.xml
                                                <page name="eclipse plugin" src="eclipse_plugin.mkd" />\r
                                        </menu>\r
                                        <divider />\r
+                                       <menu name="tickets" pager="true" pagerPlacement="bottom" pagerLayout="justified">
+                                         <page name="overview" src="tickets_overview.mkd" />
+                                         <page name="using" src="tickets_using.mkd" />
+                                         <page name="barnum" src="tickets_barnum.mkd" />
+                                         <page name="setup" src="tickets_setup.mkd" />
+                                       </menu>
+                                       <divider />
                                        <page name="federation" src="federation.mkd" />\r
                                        <divider />\r
                                        <page name="settings" src="properties.mkd" />\r
                                                        <page name="eclipse plugin" src="eclipse_plugin.mkd" />\r
                                                </menu>\r
                                                <divider />\r
+                                               <menu name="tickets" pager="true" pagerPlacement="bottom" pagerLayout="justified">
+                                                       <page name="overview" src="tickets_overview.mkd" />
+                                                       <page name="using" src="tickets_using.mkd" />
+                                                       <page name="barnum" src="tickets_barnum.mkd" />
+                                                       <page name="setup" src="tickets_setup.mkd" />
+                                               </menu>
+                                               <divider />
                                                <page name="federation" src="federation.mkd" />\r
                                                <divider />\r
                                                <page name="settings" src="properties.mkd" />\r
index 19003661fc1756d11c21b5f33510fe18843374dc..7ebe2e89a2ba2ab292351514e8751623fa2c266d 100644 (file)
         </SOURCES>
       </library>
     </orderEntry>
+    <orderEntry type="module-library">
+      <library name="jedis-2.3.1.jar">
+        <CLASSES>
+          <root url="jar://$MODULE_DIR$/ext/jedis-2.3.1.jar!/" />
+        </CLASSES>
+        <JAVADOC />
+        <SOURCES>
+          <root url="jar://$MODULE_DIR$/ext/src/jedis-2.3.1.jar!/" />
+        </SOURCES>
+      </library>
+    </orderEntry>
+    <orderEntry type="module-library">
+      <library name="commons-pool2-2.0.jar">
+        <CLASSES>
+          <root url="jar://$MODULE_DIR$/ext/commons-pool2-2.0.jar!/" />
+        </CLASSES>
+        <JAVADOC />
+        <SOURCES>
+          <root url="jar://$MODULE_DIR$/ext/src/commons-pool2-2.0.jar!/" />
+        </SOURCES>
+      </library>
+    </orderEntry>
     <orderEntry type="module-library" scope="TEST">
       <library name="junit-4.11.jar">
         <CLASSES>
index f7af6cb2eb1b356de51f02015d89386920616050..f64e648a1b97bf7b5db4d59a1ba5d767670ded68 100644 (file)
@@ -59,6 +59,7 @@ r20: {
        - Added an optional MirrorService which will periodically fetch ref updates from source repositories for mirrors (issue-5).  Repositories must be manually cloned using native git and "--mirror".
        - Added branch graph image servlet based on EGit's branch graph renderer (issue-194)
        - Added option to render Markdown commit messages (issue-203)
+       - Added Ticket tracker and Patchset collaboration feature (issue-215) 
        - Added setting to control creating a repository as --shared on Unix servers (issue-263)
        - Set Link: <url>; rel="canonical" http header for SEO (issue-304)
        - Added raw links to the commit, commitdiff, and compare pages (issue-319)
@@ -86,6 +87,7 @@ r20: {
        - added Dagger 1.1.0
        - added Eclipse WikiText libraries for processing confluence, mediawiki, textile, tracwiki, and twiki
        - added FontAwesome 4.0.3
+       - added Jedis 2.3.1
     settings:
     - { name: 'git.createRepositoriesShared', defaultValue: 'false' }
     - { name: 'git.allowAnonymousPushes', defaultValue: 'false' }
@@ -105,6 +107,10 @@ r20: {
        - { name: 'web.showBranchGraph', defaultValue: 'true' }
        - { name: 'web.summaryShowReadme', defaultValue: 'false' }
        - { name: 'server.redirectToHttpsPort', defaultValue: 'false' }
+       - { name: 'tickets.service', defaultValue: ' ' }
+       - { name: 'tickets.acceptNewTickets', defaultValue: 'true' }
+       - { name: 'tickets.acceptNewPatchsets', defaultValue: 'true' }
+       - { name: 'tickets.requireApproval', defaultValue: 'false' }
     contributors:
        - James Moger
        - Robin Rosenberg
index 2b15cd38f6865a1e15ca8fcb86ec5c8e02d37f60..31e53efdc8ca60e930fab31d9489c8dca03ef742 100644 (file)
@@ -9,6 +9,17 @@
                "icon": "git-black_32x32.png",\r
                "isActive": true\r
        },\r
+       {\r
+               "name": "Barnum",\r
+               "title": "Barnum",\r
+               "description": "a command-line Git companion for Gitblit Tickets",\r
+               "legal": "released under the Apache 2.0 License",\r
+               "command": "pt clone ${repoUrl}",\r
+               "productUrl": "http://barnum.gitblit.com",\r
+               "transports": [ "ssh" ],\r
+               "icon": "barnum_32x32.png",\r
+               "isActive": false\r
+       },      \r
        {\r
                "name": "SmartGit/Hg",\r
                "title": "syntevo SmartGit/Hg\u2122",\r
@@ -73,6 +84,7 @@
                "legal": "released under the GPLv3 open source license",\r
                "cloneUrl": "sparkleshare://addProject/${baseUrl}/sparkleshare/${repoUrl}.xml",\r
                "productUrl": "http://sparkleshare.org",\r
+               "transports": [ "ssh" ],\r
                "platforms": [ "windows", "macintosh", "linux" ],\r
                "icon": "sparkleshare_32x32.png",\r
                "minimumPermission" : "RW+",\r
index 5a08326464df0b9ffc1c3690908cdbefd9c9a112..73c6ebd54e2200a829f8730fd1a55fe162ab3525 100644 (file)
@@ -429,6 +429,76 @@ git.streamFileThreshold = 50m
 # RESTART REQUIRED\r
 git.packedGitMmap = false\r
 \r
+# Use the Gitblit patch receive pack for processing contributions and tickets.\r
+# This allows the user to push a patch using the familiar Gerrit syntax:\r
+#\r
+#    git push <remote> HEAD:refs/for/<targetBranch>\r
+#\r
+# NOTE:\r
+# This requires git.enableGitServlet = true AND it requires an authenticated\r
+# git transport connection (http/https) when pushing from a client.\r
+#\r
+# Valid services include:\r
+#    com.gitblit.tickets.FileTicketService\r
+#    com.gitblit.tickets.BranchTicketService\r
+#    com.gitblit.tickets.RedisTicketService\r
+#\r
+# SINCE 1.4.0\r
+# RESTART REQUIRED\r
+tickets.service = \r
+\r
+# Globally enable or disable creation of new bug, enhancement, task, etc tickets\r
+# for all repositories.\r
+#\r
+# If false, no tickets can be created through the ui for any repositories.\r
+# If true, each repository can control if they allow new tickets to be created.\r
+#\r
+# NOTE:\r
+# If a repository is accepting patchsets, new proposal tickets can be created\r
+# regardless of this setting.\r
+#\r
+# SINCE 1.4.0\r
+tickets.acceptNewTickets = true\r
+\r
+# Globally enable or disable pushing patchsets to all repositories.\r
+#\r
+# If false, no patchsets will be accepted for any repositories.\r
+# If true, each repository can control if they accept new patchsets.\r
+#\r
+# NOTE:\r
+# If a repository is accepting patchsets, new proposal tickets can be created\r
+# regardless of the acceptNewTickets setting.\r
+#\r
+# SINCE 1.4.0\r
+tickets.acceptNewPatchsets = true\r
+\r
+# Default setting to control patchset merge through the web ui.  If true, patchsets\r
+# must have an approval score to enable the merge button.  This setting can be\r
+# overriden per-repository.\r
+#\r
+# SINCE 1.4.0\r
+tickets.requireApproval = false\r
+\r
+# Specify the location of the Lucene Ticket index\r
+#\r
+# SINCE 1.4.0\r
+# RESTART REQUIRED\r
+tickets.indexFolder = ${baseFolder}/tickets/lucene\r
+\r
+# Define the url for the Redis server.\r
+#\r
+# e.g. redis://localhost:6379\r
+#      redis://:foobared@localhost:6379/2\r
+#\r
+# SINCE 1.4.0\r
+# RESTART REQUIRED\r
+tickets.redis.url =\r
+\r
+# The number of tickets to display on a page.\r
+#\r
+# SINCE 1.4.0\r
+tickets.perPage = 25\r
+\r
 #\r
 # Groovy Integration\r
 #\r
diff --git a/src/main/distrib/linux/reindex-tickets.sh b/src/main/distrib/linux/reindex-tickets.sh
new file mode 100644 (file)
index 0000000..1593929
--- /dev/null
@@ -0,0 +1,15 @@
+#!/bin/bash
+# --------------------------------------------------------------------------
+# This is for reindexing Tickets with Lucene.
+#
+# Since the Tickets feature is undergoing massive churn it may be necessary 
+# to reindex tickets due to model or index changes.
+#
+# usage:
+#
+#     reindex-tickets.sh <baseFolder>
+#
+# --------------------------------------------------------------------------
+
+java -cp gitblit.jar:./ext/* com.gitblit.ReindexTickets --baseFolder $1
+
diff --git a/src/main/distrib/win/reindex-tickets.cmd b/src/main/distrib/win/reindex-tickets.cmd
new file mode 100644 (file)
index 0000000..e28f45f
--- /dev/null
@@ -0,0 +1,13 @@
+@REM --------------------------------------------------------------------------\r
+@REM This is for reindexing Tickets with Lucene.\r
+@REM\r
+@REM Since the Tickets feature is undergoing massive churn it may be necessary \r
+@REM to reindex tickets due to model or index changes.\r
+@REM\r
+@REM Always use forward-slashes for the path separator in your parameters!!\r
+@REM\r
+@REM Set FOLDER to the baseFolder.\r
+@REM --------------------------------------------------------------------------\r
+@SET FOLDER=data\r
+\r
+@java -cp gitblit.jar;"%CD%\ext\*" com.gitblit.ReindexTickets --baseFolder %FOLDER%\r
index 8f51c21b69426def233eddb3ef4c56f130801206..1451ec632472f17ca80169fcbf01604ed19d0bad 100644 (file)
                <url-pattern>/logo.png</url-pattern>\r
        </servlet-mapping>\r
 \r
+\r
+       <!-- PT Servlet\r
+                <url-pattern> MUST match: \r
+                       * Wicket Filter ignorePaths parameter -->\r
+       <servlet>\r
+               <servlet-name>PtServlet</servlet-name>\r
+               <servlet-class>com.gitblit.servlet.PtServlet</servlet-class>\r
+       </servlet>\r
+       <servlet-mapping>\r
+               <servlet-name>PtServlet</servlet-name>          \r
+               <url-pattern>/pt</url-pattern>\r
+       </servlet-mapping>\r
+\r
+\r
        <!-- Branch Graph Servlet\r
                 <url-pattern> MUST match: \r
                        * Wicket Filter ignorePaths parameter -->\r
                * PagesFilter <url-pattern>\r
                * PagesServlet <url-pattern>\r
                * com.gitblit.Constants.PAGES_PATH -->\r
-            <param-value>r/,git/,feed/,zip/,federation/,rpc/,pages/,robots.txt,logo.png,graph/,sparkleshare/</param-value>\r
+            <param-value>r/,git/,pt,feed/,zip/,federation/,rpc/,pages/,robots.txt,logo.png,graph/,sparkleshare/</param-value>\r
         </init-param>\r
     </filter>\r
     <filter-mapping>\r
index d425cdac1fa230f1543ca526ac153c8166151776..5b71eeb959e5eaf72e4689e6379a06f46aca0b86 100644 (file)
@@ -108,12 +108,18 @@ public class Constants {
 \r
        public static final String R_CHANGES = "refs/changes/";\r
 \r
-       public static final String R_PULL= "refs/pull/";\r
+       public static final String R_PULL = "refs/pull/";\r
 \r
        public static final String R_TAGS = "refs/tags/";\r
 \r
        public static final String R_REMOTES = "refs/remotes/";\r
 \r
+       public static final String R_FOR = "refs/for/";\r
+\r
+       public static final String R_TICKET = "refs/heads/ticket/";\r
+\r
+       public static final String R_TICKETS_PATCHSETS = "refs/tickets/";\r
+\r
        public static String getVersion() {\r
                String v = Constants.class.getPackage().getImplementationVersion();\r
                if (v == null) {\r
index da80a746b4b90646e2278c4bbf6c15eb0f40a499..a1abfcd16dd33f5dad412b37cd4edb02dede1b5e 100644 (file)
@@ -19,12 +19,14 @@ import java.text.MessageFormat;
 import java.util.ArrayList;
 import java.util.List;
 
+import javax.inject.Singleton;
 import javax.servlet.http.HttpServletRequest;
 
 import com.gitblit.Constants.AccessPermission;
 import com.gitblit.manager.GitblitManager;
 import com.gitblit.manager.IAuthenticationManager;
 import com.gitblit.manager.IFederationManager;
+import com.gitblit.manager.IGitblit;
 import com.gitblit.manager.INotificationManager;
 import com.gitblit.manager.IProjectManager;
 import com.gitblit.manager.IRepositoryManager;
@@ -34,8 +36,17 @@ import com.gitblit.manager.ServicesManager;
 import com.gitblit.models.RepositoryModel;
 import com.gitblit.models.RepositoryUrl;
 import com.gitblit.models.UserModel;
+import com.gitblit.tickets.BranchTicketService;
+import com.gitblit.tickets.FileTicketService;
+import com.gitblit.tickets.ITicketService;
+import com.gitblit.tickets.NullTicketService;
+import com.gitblit.tickets.RedisTicketService;
 import com.gitblit.utils.StringUtils;
 
+import dagger.Module;
+import dagger.ObjectGraph;
+import dagger.Provides;
+
 /**
  * GitBlit is the aggregate manager for the Gitblit webapp.  It provides all
  * management functions and also manages some long-running services.
@@ -45,8 +56,12 @@ import com.gitblit.utils.StringUtils;
  */
 public class GitBlit extends GitblitManager {
 
+       private final ObjectGraph injector;
+
        private final ServicesManager servicesManager;
 
+       private ITicketService ticketService;
+
        public GitBlit(
                        IRuntimeManager runtimeManager,
                        INotificationManager notificationManager,
@@ -64,6 +79,8 @@ public class GitBlit extends GitblitManager {
                                projectManager,
                                federationManager);
 
+               this.injector = ObjectGraph.create(getModules());
+
                this.servicesManager = new ServicesManager(this);
        }
 
@@ -72,6 +89,7 @@ public class GitBlit extends GitblitManager {
                super.start();
                logger.info("Starting services manager...");
                servicesManager.start();
+               configureTicketService();
                return this;
        }
 
@@ -79,9 +97,14 @@ public class GitBlit extends GitblitManager {
        public GitBlit stop() {
                super.stop();
                servicesManager.stop();
+               ticketService.stop();
                return this;
        }
 
+       protected Object [] getModules() {
+               return new Object [] { new GitBlitModule()};
+       }
+
        /**
         * Returns a list of repository URLs and the user access permission.
         *
@@ -131,4 +154,135 @@ public class GitBlit extends GitblitManager {
                }
                return list;
        }
+
+       /**
+        * Detect renames and reindex as appropriate.
+        */
+       @Override
+       public void updateRepositoryModel(String repositoryName, RepositoryModel repository,
+                       boolean isCreate) throws GitBlitException {
+               RepositoryModel oldModel = null;
+               boolean isRename = !isCreate && !repositoryName.equalsIgnoreCase(repository.name);
+               if (isRename) {
+                       oldModel = repositoryManager.getRepositoryModel(repositoryName);
+               }
+
+               super.updateRepositoryModel(repositoryName, repository, isCreate);
+
+               if (isRename && ticketService != null) {
+                       ticketService.rename(oldModel, repository);
+               }
+       }
+
+       /**
+        * Delete the repository and all associated tickets.
+        */
+       @Override
+       public boolean deleteRepository(String repositoryName) {
+               RepositoryModel repository = repositoryManager.getRepositoryModel(repositoryName);
+               boolean success = repositoryManager.deleteRepository(repositoryName);
+               if (success && ticketService != null) {
+                       return ticketService.deleteAll(repository);
+               }
+               return success;
+       }
+
+       /**
+        * Returns the configured ticket service.
+        *
+        * @return a ticket service
+        */
+       @Override
+       public ITicketService getTicketService() {
+               return ticketService;
+       }
+
+       protected void configureTicketService() {
+               String clazz = settings.getString(Keys.tickets.service, NullTicketService.class.getName());
+               if (StringUtils.isEmpty(clazz)) {
+                       clazz = NullTicketService.class.getName();
+               }
+               try {
+                       Class<? extends ITicketService> serviceClass = (Class<? extends ITicketService>) Class.forName(clazz);
+                       ticketService = injector.get(serviceClass).start();
+                       if (ticketService.isReady()) {
+                               logger.info("{} is ready.", ticketService);
+                       } else {
+                               logger.warn("{} is disabled.", ticketService);
+                       }
+               } catch (Exception e) {
+                       logger.error("failed to create ticket service " + clazz, e);
+                       ticketService = injector.get(NullTicketService.class).start();
+               }
+       }
+
+       /**
+        * A nested Dagger graph is used for constructor dependency injection of
+        * complex classes.
+        *
+        * @author James Moger
+        *
+        */
+       @Module(
+                       library = true,
+                       injects = {
+                                       IStoredSettings.class,
+
+                                       // core managers
+                                       IRuntimeManager.class,
+                                       INotificationManager.class,
+                                       IUserManager.class,
+                                       IAuthenticationManager.class,
+                                       IRepositoryManager.class,
+                                       IProjectManager.class,
+                                       IFederationManager.class,
+
+                                       // the monolithic manager
+                                       IGitblit.class,
+
+                                       // ticket services
+                                       NullTicketService.class,
+                                       FileTicketService.class,
+                                       BranchTicketService.class,
+                                       RedisTicketService.class
+                       }
+                       )
+       class GitBlitModule {
+
+               @Provides @Singleton IStoredSettings provideSettings() {
+                       return settings;
+               }
+
+               @Provides @Singleton IRuntimeManager provideRuntimeManager() {
+                       return runtimeManager;
+               }
+
+               @Provides @Singleton INotificationManager provideNotificationManager() {
+                       return notificationManager;
+               }
+
+               @Provides @Singleton IUserManager provideUserManager() {
+                       return userManager;
+               }
+
+               @Provides @Singleton IAuthenticationManager provideAuthenticationManager() {
+                       return authenticationManager;
+               }
+
+               @Provides @Singleton IRepositoryManager provideRepositoryManager() {
+                       return repositoryManager;
+               }
+
+               @Provides @Singleton IProjectManager provideProjectManager() {
+                       return projectManager;
+               }
+
+               @Provides @Singleton IFederationManager provideFederationManager() {
+                       return federationManager;
+               }
+
+               @Provides @Singleton IGitblit provideGitblit() {
+                       return GitBlit.this;
+               }
+       }
 }
diff --git a/src/main/java/com/gitblit/ReindexTickets.java b/src/main/java/com/gitblit/ReindexTickets.java
new file mode 100644 (file)
index 0000000..af3ca0b
--- /dev/null
@@ -0,0 +1,183 @@
+/*
+ * Copyright 2014 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit;
+
+import java.io.File;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.List;
+
+import com.beust.jcommander.JCommander;
+import com.beust.jcommander.Parameter;
+import com.beust.jcommander.ParameterException;
+import com.beust.jcommander.Parameters;
+import com.gitblit.manager.IRepositoryManager;
+import com.gitblit.manager.IRuntimeManager;
+import com.gitblit.manager.RepositoryManager;
+import com.gitblit.manager.RuntimeManager;
+import com.gitblit.tickets.BranchTicketService;
+import com.gitblit.tickets.FileTicketService;
+import com.gitblit.tickets.ITicketService;
+import com.gitblit.tickets.RedisTicketService;
+import com.gitblit.utils.StringUtils;
+
+/**
+ * A command-line tool to reindex all tickets in all repositories when the
+ * indexes needs to be rebuilt.
+ *
+ * @author James Moger
+ *
+ */
+public class ReindexTickets {
+
+       public static void main(String... args) {
+               ReindexTickets reindex = new ReindexTickets();
+
+               // filter out the baseFolder parameter
+               List<String> filtered = new ArrayList<String>();
+               String folder = "data";
+               for (int i = 0; i < args.length; i++) {
+                       String arg = args[i];
+                       if (arg.equals("--baseFolder")) {
+                               if (i + 1 == args.length) {
+                                       System.out.println("Invalid --baseFolder parameter!");
+                                       System.exit(-1);
+                               } else if (!".".equals(args[i + 1])) {
+                                       folder = args[i + 1];
+                               }
+                               i = i + 1;
+                       } else {
+                               filtered.add(arg);
+                       }
+               }
+
+               Params.baseFolder = folder;
+               Params params = new Params();
+               JCommander jc = new JCommander(params);
+               try {
+                       jc.parse(filtered.toArray(new String[filtered.size()]));
+                       if (params.help) {
+                               reindex.usage(jc, null);
+                               return;
+                       }
+               } catch (ParameterException t) {
+                       reindex.usage(jc, t);
+                       return;
+               }
+
+               // load the settings
+               FileSettings settings = params.FILESETTINGS;
+               if (!StringUtils.isEmpty(params.settingsfile)) {
+                       if (new File(params.settingsfile).exists()) {
+                               settings = new FileSettings(params.settingsfile);
+                       }
+               }
+
+               // reindex tickets
+               reindex.reindex(new File(Params.baseFolder), settings);
+               System.exit(0);
+       }
+
+       /**
+        * Display the command line usage of ReindexTickets.
+        *
+        * @param jc
+        * @param t
+        */
+       protected final void usage(JCommander jc, ParameterException t) {
+               System.out.println(Constants.BORDER);
+               System.out.println(Constants.getGitBlitVersion());
+               System.out.println(Constants.BORDER);
+               System.out.println();
+               if (t != null) {
+                       System.out.println(t.getMessage());
+                       System.out.println();
+               }
+               if (jc != null) {
+                       jc.usage();
+                       System.out
+                                       .println("\nExample:\n  java -gitblit.jar com.gitblit.ReindexTickets --baseFolder c:\\gitblit-data");
+               }
+               System.exit(0);
+       }
+
+       /**
+        * Reindex all tickets
+        *
+        * @param settings
+        */
+       protected void reindex(File baseFolder, IStoredSettings settings) {
+               // disable some services
+               settings.overrideSetting(Keys.web.allowLuceneIndexing, false);
+               settings.overrideSetting(Keys.git.enableGarbageCollection, false);
+               settings.overrideSetting(Keys.git.enableMirroring, false);
+               settings.overrideSetting(Keys.web.activityCacheDays, 0);
+
+               IRuntimeManager runtimeManager = new RuntimeManager(settings, baseFolder).start();
+               IRepositoryManager repositoryManager = new RepositoryManager(runtimeManager, null).start();
+
+               String serviceName = settings.getString(Keys.tickets.service, BranchTicketService.class.getSimpleName());
+               if (StringUtils.isEmpty(serviceName)) {
+                       System.err.println(MessageFormat.format("Please define a ticket service in \"{0}\"", Keys.tickets.service));
+                       System.exit(1);
+               }
+               ITicketService ticketService = null;
+               try {
+                       Class<?> serviceClass = Class.forName(serviceName);
+                       if (RedisTicketService.class.isAssignableFrom(serviceClass)) {
+                               // Redis ticket service
+                               ticketService = new RedisTicketService(runtimeManager, null, null, repositoryManager).start();
+                       } else if (BranchTicketService.class.isAssignableFrom(serviceClass)) {
+                               // Branch ticket service
+                               ticketService = new BranchTicketService(runtimeManager, null, null, repositoryManager).start();
+                       } else if (FileTicketService.class.isAssignableFrom(serviceClass)) {
+                               // File ticket service
+                               ticketService = new FileTicketService(runtimeManager, null, null, repositoryManager).start();
+                       } else {
+                               System.err.println("Unknown ticket service " + serviceName);
+                               System.exit(1);
+                       }
+               } catch (Exception e) {
+                       e.printStackTrace();
+                       System.exit(1);
+               }
+
+               ticketService.reindex();
+               ticketService.stop();
+               repositoryManager.stop();
+               runtimeManager.stop();
+       }
+
+       /**
+        * JCommander Parameters.
+        */
+       @Parameters(separators = " ")
+       public static class Params {
+
+               public static String baseFolder;
+
+               @Parameter(names = { "-h", "--help" }, description = "Show this help")
+               public Boolean help = false;
+
+               private final FileSettings FILESETTINGS = new FileSettings(new File(baseFolder, Constants.PROPERTIES_FILE).getAbsolutePath());
+
+               @Parameter(names = { "--repositoriesFolder" }, description = "Git Repositories Folder")
+               public String repositoriesFolder = FILESETTINGS.getString(Keys.git.repositoriesFolder, "git");
+
+               @Parameter(names = { "--settings" }, description = "Path to alternative settings")
+               public String settingsfile;
+       }
+}
index ce22d72f77562ce997214d121206b68e4b9e0d3d..c3690f374622320424050f7367fee02e0277f23b 100644 (file)
@@ -88,6 +88,12 @@ public class EditRepositoryDialog extends JDialog {
 \r
        private JTextField descriptionField;\r
 \r
+       private JCheckBox acceptNewPatchsets;\r
+\r
+       private JCheckBox acceptNewTickets;\r
+\r
+       private JCheckBox requireApproval;
+
        private JCheckBox useIncrementalPushTags;\r
 \r
        private JCheckBox showRemoteBranches;\r
@@ -205,6 +211,12 @@ public class EditRepositoryDialog extends JDialog {
 \r
                ownersPalette = new JPalette<String>(true);\r
 \r
+               acceptNewTickets = new JCheckBox(Translation.get("gb.acceptsNewTicketsDescription"),\r
+                               anRepository.acceptNewTickets);\r
+               acceptNewPatchsets = new JCheckBox(Translation.get("gb.acceptsNewPatchsetsDescription"),\r
+                               anRepository.acceptNewPatchsets);\r
+               requireApproval = new JCheckBox(Translation.get("gb.requireApprovalDescription"),\r
+                               anRepository.requireApproval);\r
                useIncrementalPushTags = new JCheckBox(Translation.get("gb.useIncrementalPushTagsDescription"),\r
                                anRepository.useIncrementalPushTags);\r
                showRemoteBranches = new JCheckBox(\r
@@ -298,6 +310,12 @@ public class EditRepositoryDialog extends JDialog {
                fieldsPanel.add(newFieldPanel(Translation.get("gb.gcPeriod"), gcPeriod));\r
                fieldsPanel.add(newFieldPanel(Translation.get("gb.gcThreshold"), gcThreshold));\r
 \r
+               fieldsPanel.add(newFieldPanel(Translation.get("gb.acceptsNewTickets"),\r
+                               acceptNewTickets));\r
+               fieldsPanel.add(newFieldPanel(Translation.get("gb.acceptsNewPatchsets"),\r
+                               acceptNewPatchsets));\r
+               fieldsPanel.add(newFieldPanel(Translation.get("gb.requireApproval"),\r
+                               requireApproval));\r
                fieldsPanel\r
                .add(newFieldPanel(Translation.get("gb.enableIncrementalPushTags"), useIncrementalPushTags));\r
                fieldsPanel.add(newFieldPanel(Translation.get("gb.showRemoteBranches"),\r
@@ -552,6 +570,9 @@ public class EditRepositoryDialog extends JDialog {
                                : headRefField.getSelectedItem().toString();\r
                repository.gcPeriod = (Integer) gcPeriod.getSelectedItem();\r
                repository.gcThreshold = gcThreshold.getText();\r
+               repository.acceptNewPatchsets = acceptNewPatchsets.isSelected();\r
+               repository.acceptNewTickets = acceptNewTickets.isSelected();\r
+               repository.requireApproval = requireApproval.isSelected();
                repository.useIncrementalPushTags = useIncrementalPushTags.isSelected();\r
                repository.showRemoteBranches = showRemoteBranches.isSelected();\r
                repository.skipSizeCalculation = skipSizeCalculation.isSelected();\r
index 35f0d866b40592569b7d743daa3d8c22e88de39c..3a0eff2212ce408e7c59aa2c3f7fd1f9a00fca2a 100644 (file)
@@ -50,6 +50,7 @@ import com.gitblit.client.Translation;
 import com.gitblit.manager.IGitblit;\r
 import com.gitblit.models.RepositoryModel;\r
 import com.gitblit.models.UserModel;\r
+import com.gitblit.tickets.BranchTicketService;\r
 import com.gitblit.utils.ArrayUtils;\r
 import com.gitblit.utils.ClientLogger;\r
 import com.gitblit.utils.CommitCache;\r
@@ -236,6 +237,16 @@ public class GitblitReceivePack extends ReceivePack implements PreReceiveHook, P
                                default:\r
                                        break;\r
                                }\r
+                       } else if (ref.equals(BranchTicketService.BRANCH)) {\r
+                               // ensure pushing user is an administrator OR an owner\r
+                               // i.e. prevent ticket tampering\r
+                               boolean permitted = user.canAdmin() || repository.isOwner(user.username);\r
+                               if (!permitted) {\r
+                                       sendRejection(cmd, "{0} is not permitted to push to {1}", user.username, ref);\r
+                               }\r
+                       } else if (ref.startsWith(Constants.R_FOR)) {\r
+                               // prevent accidental push to refs/for\r
+                               sendRejection(cmd, "{0} is not configured to receive patchsets", repository.name);\r
                        }\r
                }\r
 \r
index b8b49bcdf94c4a60b93c9e8a0c98f562d07be5a0..7976fe5624e0122a6cb0f7b25fd532bebd1e3d62 100644 (file)
@@ -100,10 +100,17 @@ public class GitblitReceivePackFactory<X> implements ReceivePackFactory<X> {
                if (StringUtils.isEmpty(url)) {
                        url = gitblitUrl;
                }
-               
+
                final RepositoryModel repository = gitblit.getRepositoryModel(repositoryName);
 
-               final GitblitReceivePack rp = new GitblitReceivePack(gitblit, db, repository, user);
+               // Determine which receive pack to use for pushes
+               final GitblitReceivePack rp;
+               if (gitblit.getTicketService().isAcceptingNewPatchsets(repository)) {
+                       rp = new PatchsetReceivePack(gitblit, db, repository, user);
+               } else {
+                       rp = new GitblitReceivePack(gitblit, db, repository, user);
+               }
+
                rp.setGitblitUrl(url);
                rp.setRefLogIdent(new PersonIdent(user.username, user.username + "@" + origin));
                rp.setTimeout(timeout);
diff --git a/src/main/java/com/gitblit/git/PatchsetCommand.java b/src/main/java/com/gitblit/git/PatchsetCommand.java
new file mode 100644 (file)
index 0000000..21d2ac4
--- /dev/null
@@ -0,0 +1,324 @@
+/*
+ * Copyright 2013 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.git;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+import com.gitblit.Constants;
+import com.gitblit.models.TicketModel;
+import com.gitblit.models.TicketModel.Change;
+import com.gitblit.models.TicketModel.Field;
+import com.gitblit.models.TicketModel.Patchset;
+import com.gitblit.models.TicketModel.PatchsetType;
+import com.gitblit.models.TicketModel.Status;
+import com.gitblit.utils.ArrayUtils;
+import com.gitblit.utils.StringUtils;
+
+/**
+ *
+ * A subclass of ReceiveCommand which constructs a ticket change based on a
+ * patchset and data derived from the push ref.
+ *
+ * @author James Moger
+ *
+ */
+public class PatchsetCommand extends ReceiveCommand {
+
+       public static final String TOPIC = "t=";
+
+       public static final String RESPONSIBLE = "r=";
+
+       public static final String WATCH = "cc=";
+
+       public static final String MILESTONE = "m=";
+
+       protected final Change change;
+
+       protected boolean isNew;
+
+       protected long ticketId;
+
+       public static String getBasePatchsetBranch(long ticketNumber) {
+               StringBuilder sb = new StringBuilder();
+               sb.append(Constants.R_TICKETS_PATCHSETS);
+               long m = ticketNumber % 100L;
+               if (m < 10) {
+                       sb.append('0');
+               }
+               sb.append(m);
+               sb.append('/');
+               sb.append(ticketNumber);
+               sb.append('/');
+               return sb.toString();
+       }
+
+       public static String getTicketBranch(long ticketNumber) {
+               return Constants.R_TICKET + ticketNumber;
+       }
+
+       public static String getReviewBranch(long ticketNumber) {
+               return "ticket-" + ticketNumber;
+       }
+
+       public static String getPatchsetBranch(long ticketId, long patchset) {
+               return getBasePatchsetBranch(ticketId) + patchset;
+       }
+
+       public static long getTicketNumber(String ref) {
+               if (ref.startsWith(Constants.R_TICKETS_PATCHSETS)) {
+                       // patchset revision
+
+                       // strip changes ref
+                       String p = ref.substring(Constants.R_TICKETS_PATCHSETS.length());
+                       // strip shard id
+                       p = p.substring(p.indexOf('/') + 1);
+                       // strip revision
+                       p = p.substring(0, p.indexOf('/'));
+                       // parse ticket number
+                       return Long.parseLong(p);
+               } else if (ref.startsWith(Constants.R_TICKET)) {
+                       String p = ref.substring(Constants.R_TICKET.length());
+                       // parse ticket number
+                       return Long.parseLong(p);
+               }
+               return 0L;
+       }
+
+       public PatchsetCommand(String username, Patchset patchset) {
+               super(patchset.isFF() ? ObjectId.fromString(patchset.parent) : ObjectId.zeroId(),
+                               ObjectId.fromString(patchset.tip), null);
+               this.change = new Change(username);
+               this.change.patchset = patchset;
+       }
+
+       public PatchsetType getPatchsetType() {
+               return change.patchset.type;
+       }
+
+       public boolean isNewTicket() {
+               return isNew;
+       }
+
+       public long getTicketId() {
+               return ticketId;
+       }
+
+       public Change getChange() {
+               return change;
+       }
+
+       /**
+        * Creates a "new ticket" change for the proposal.
+        *
+        * @param commit
+        * @param mergeTo
+        * @param ticketId
+        * @parem pushRef
+        */
+       public void newTicket(RevCommit commit, String mergeTo, long ticketId, String pushRef) {
+               this.ticketId = ticketId;
+               isNew = true;
+               change.setField(Field.title, getTitle(commit));
+               change.setField(Field.body, getBody(commit));
+               change.setField(Field.status, Status.New);
+               change.setField(Field.mergeTo, mergeTo);
+               change.setField(Field.type, TicketModel.Type.Proposal);
+
+               Set<String> watchSet = new TreeSet<String>();
+               watchSet.add(change.author);
+
+               // identify parameters passed in the push ref
+               if (!StringUtils.isEmpty(pushRef)) {
+                       List<String> watchers = getOptions(pushRef, WATCH);
+                       if (!ArrayUtils.isEmpty(watchers)) {
+                               for (String cc : watchers) {
+                                       watchSet.add(cc.toLowerCase());
+                               }
+                       }
+
+                       String milestone = getSingleOption(pushRef, MILESTONE);
+                       if (!StringUtils.isEmpty(milestone)) {
+                               // user provided milestone
+                               change.setField(Field.milestone, milestone);
+                       }
+
+                       String responsible = getSingleOption(pushRef, RESPONSIBLE);
+                       if (!StringUtils.isEmpty(responsible)) {
+                               // user provided responsible
+                               change.setField(Field.responsible, responsible);
+                               watchSet.add(responsible);
+                       }
+
+                       String topic = getSingleOption(pushRef, TOPIC);
+                       if (!StringUtils.isEmpty(topic)) {
+                               // user provided topic
+                               change.setField(Field.topic, topic);
+                       }
+               }
+
+               // set the watchers
+               change.watch(watchSet.toArray(new String[watchSet.size()]));
+       }
+
+       /**
+        *
+        * @param commit
+        * @param mergeTo
+        * @param ticket
+        * @param pushRef
+        */
+       public void updateTicket(RevCommit commit, String mergeTo, TicketModel ticket, String pushRef) {
+
+               this.ticketId = ticket.number;
+
+               if (ticket.isClosed()) {
+                       // re-opening a closed ticket
+                       change.setField(Field.status, Status.Open);
+               }
+
+               // ticket may or may not already have an integration branch
+               if (StringUtils.isEmpty(ticket.mergeTo) || !ticket.mergeTo.equals(mergeTo)) {
+                       change.setField(Field.mergeTo, mergeTo);
+               }
+
+               if (ticket.isProposal() && change.patchset.commits == 1 && change.patchset.type.isRewrite()) {
+
+                       // Gerrit-style title and description updates from the commit
+                       // message
+                       String title = getTitle(commit);
+                       String body = getBody(commit);
+
+                       if (!ticket.title.equals(title)) {
+                               // title changed
+                               change.setField(Field.title, title);
+                       }
+
+                       if (!ticket.body.equals(body)) {
+                               // description changed
+                               change.setField(Field.body, body);
+                       }
+               }
+
+               Set<String> watchSet = new TreeSet<String>();
+               watchSet.add(change.author);
+
+               // update the patchset command metadata
+               if (!StringUtils.isEmpty(pushRef)) {
+                       List<String> watchers = getOptions(pushRef, WATCH);
+                       if (!ArrayUtils.isEmpty(watchers)) {
+                               for (String cc : watchers) {
+                                       watchSet.add(cc.toLowerCase());
+                               }
+                       }
+
+                       String milestone = getSingleOption(pushRef, MILESTONE);
+                       if (!StringUtils.isEmpty(milestone) && !milestone.equals(ticket.milestone)) {
+                               // user specified a (different) milestone
+                               change.setField(Field.milestone, milestone);
+                       }
+
+                       String responsible = getSingleOption(pushRef, RESPONSIBLE);
+                       if (!StringUtils.isEmpty(responsible) && !responsible.equals(ticket.responsible)) {
+                               // user specified a (different) responsible
+                               change.setField(Field.responsible, responsible);
+                               watchSet.add(responsible);
+                       }
+
+                       String topic = getSingleOption(pushRef, TOPIC);
+                       if (!StringUtils.isEmpty(topic) && !topic.equals(ticket.topic)) {
+                               // user specified a (different) topic
+                               change.setField(Field.topic, topic);
+                       }
+               }
+
+               // update the watchers
+               watchSet.removeAll(ticket.getWatchers());
+               if (!watchSet.isEmpty()) {
+                       change.watch(watchSet.toArray(new String[watchSet.size()]));
+               }
+       }
+
+       @Override
+       public String getRefName() {
+               return getPatchsetBranch();
+       }
+
+       public String getPatchsetBranch() {
+               return getBasePatchsetBranch(ticketId) + change.patchset.number;
+       }
+
+       public String getTicketBranch() {
+               return getTicketBranch(ticketId);
+       }
+
+       private String getTitle(RevCommit commit) {
+               String title = commit.getShortMessage();
+               return title;
+       }
+
+       /**
+        * Returns the body of the commit message
+        *
+        * @return
+        */
+       private String getBody(RevCommit commit) {
+               String body = commit.getFullMessage().substring(commit.getShortMessage().length()).trim();
+               return body;
+       }
+
+       /** Extracts a ticket field from the ref name */
+       private static List<String> getOptions(String refName, String token) {
+               if (refName.indexOf('%') > -1) {
+                       List<String> list = new ArrayList<String>();
+                       String [] strings = refName.substring(refName.indexOf('%') + 1).split(",");
+                       for (String str : strings) {
+                               if (str.toLowerCase().startsWith(token)) {
+                                       String val = str.substring(token.length());
+                                       list.add(val);
+                               }
+                       }
+                       return list;
+               }
+               return null;
+       }
+
+       /** Extracts a ticket field from the ref name */
+       private static String getSingleOption(String refName, String token) {
+               List<String> list = getOptions(refName, token);
+               if (list != null && list.size() > 0) {
+                       return list.get(0);
+               }
+               return null;
+       }
+
+       /** Extracts a ticket field from the ref name */
+       public static String getSingleOption(ReceiveCommand cmd, String token) {
+               return getSingleOption(cmd.getRefName(), token);
+       }
+
+       /** Extracts a ticket field from the ref name */
+       public static List<String> getOptions(ReceiveCommand cmd, String token) {
+               return getOptions(cmd.getRefName(), token);
+       }
+
+}
\ No newline at end of file
diff --git a/src/main/java/com/gitblit/git/PatchsetReceivePack.java b/src/main/java/com/gitblit/git/PatchsetReceivePack.java
new file mode 100644 (file)
index 0000000..ae429d2
--- /dev/null
@@ -0,0 +1,1129 @@
+/*\r
+ * Copyright 2013 gitblit.com.\r
+ *\r
+ * Licensed under the Apache License, Version 2.0 (the "License");\r
+ * you may not use this file except in compliance with the License.\r
+ * You may obtain a copy of the License at\r
+ *\r
+ *     http://www.apache.org/licenses/LICENSE-2.0\r
+ *\r
+ * Unless required by applicable law or agreed to in writing, software\r
+ * distributed under the License is distributed on an "AS IS" BASIS,\r
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r
+ * See the License for the specific language governing permissions and\r
+ * limitations under the License.\r
+ */\r
+package com.gitblit.git;\r
+\r
+import static org.eclipse.jgit.transport.BasePackPushConnection.CAPABILITY_SIDE_BAND_64K;\r
+\r
+import java.io.IOException;\r
+import java.text.MessageFormat;\r
+import java.util.ArrayList;\r
+import java.util.Arrays;\r
+import java.util.Collection;\r
+import java.util.LinkedHashMap;\r
+import java.util.List;\r
+import java.util.Map;\r
+import java.util.Set;\r
+import java.util.concurrent.TimeUnit;\r
+import java.util.regex.Matcher;\r
+import java.util.regex.Pattern;\r
+\r
+import org.eclipse.jgit.lib.AnyObjectId;\r
+import org.eclipse.jgit.lib.BatchRefUpdate;\r
+import org.eclipse.jgit.lib.NullProgressMonitor;\r
+import org.eclipse.jgit.lib.ObjectId;\r
+import org.eclipse.jgit.lib.PersonIdent;\r
+import org.eclipse.jgit.lib.ProgressMonitor;\r
+import org.eclipse.jgit.lib.Ref;\r
+import org.eclipse.jgit.lib.RefUpdate;\r
+import org.eclipse.jgit.lib.Repository;\r
+import org.eclipse.jgit.revwalk.RevCommit;\r
+import org.eclipse.jgit.revwalk.RevSort;\r
+import org.eclipse.jgit.revwalk.RevWalk;\r
+import org.eclipse.jgit.transport.ReceiveCommand;\r
+import org.eclipse.jgit.transport.ReceiveCommand.Result;\r
+import org.eclipse.jgit.transport.ReceiveCommand.Type;\r
+import org.eclipse.jgit.transport.ReceivePack;\r
+import org.slf4j.Logger;\r
+import org.slf4j.LoggerFactory;\r
+\r
+import com.gitblit.Constants;\r
+import com.gitblit.Keys;\r
+import com.gitblit.manager.IGitblit;\r
+import com.gitblit.models.RepositoryModel;\r
+import com.gitblit.models.TicketModel;\r
+import com.gitblit.models.TicketModel.Change;\r
+import com.gitblit.models.TicketModel.Field;\r
+import com.gitblit.models.TicketModel.Patchset;\r
+import com.gitblit.models.TicketModel.PatchsetType;\r
+import com.gitblit.models.TicketModel.Status;\r
+import com.gitblit.models.UserModel;\r
+import com.gitblit.tickets.ITicketService;\r
+import com.gitblit.tickets.TicketMilestone;\r
+import com.gitblit.tickets.TicketNotifier;\r
+import com.gitblit.utils.ArrayUtils;\r
+import com.gitblit.utils.DiffUtils;\r
+import com.gitblit.utils.DiffUtils.DiffStat;\r
+import com.gitblit.utils.JGitUtils;\r
+import com.gitblit.utils.JGitUtils.MergeResult;\r
+import com.gitblit.utils.JGitUtils.MergeStatus;\r
+import com.gitblit.utils.RefLogUtils;\r
+import com.gitblit.utils.StringUtils;\r
+\r
+\r
+/**\r
+ * PatchsetReceivePack processes receive commands and allows for creating, updating,\r
+ * and closing Gitblit tickets.  It also executes Groovy pre- and post- receive\r
+ * hooks.\r
+ *\r
+ * The patchset mechanism defined in this class is based on the ReceiveCommits class\r
+ * from the Gerrit code review server.\r
+ *\r
+ * The general execution flow is:\r
+ * <ol>\r
+ *    <li>onPreReceive()</li>\r
+ *    <li>executeCommands()</li>\r
+ *    <li>onPostReceive()</li>\r
+ * </ol>\r
+ *\r
+ * @author Android Open Source Project\r
+ * @author James Moger\r
+ *\r
+ */\r
+public class PatchsetReceivePack extends GitblitReceivePack {\r
+\r
+       protected static final List<String> MAGIC_REFS = Arrays.asList(Constants.R_FOR, Constants.R_TICKET);\r
+\r
+       protected static final Pattern NEW_PATCHSET =\r
+                       Pattern.compile("^refs/tickets/(?:[0-9a-zA-Z][0-9a-zA-Z]/)?([1-9][0-9]*)(?:/new)?$");\r
+\r
+       private static final Logger LOGGER = LoggerFactory.getLogger(PatchsetReceivePack.class);\r
+\r
+       protected final ITicketService ticketService;\r
+\r
+       protected final TicketNotifier ticketNotifier;\r
+\r
+       private boolean requireCleanMerge;\r
+\r
+       public PatchsetReceivePack(IGitblit gitblit, Repository db, RepositoryModel repository, UserModel user) {\r
+               super(gitblit, db, repository, user);\r
+               this.ticketService = gitblit.getTicketService();\r
+               this.ticketNotifier = ticketService.createNotifier();\r
+       }\r
+\r
+       /** Returns the patchset ref root from the ref */\r
+       private String getPatchsetRef(String refName) {\r
+               for (String patchRef : MAGIC_REFS) {\r
+                       if (refName.startsWith(patchRef)) {\r
+                               return patchRef;\r
+                       }\r
+               }\r
+               return null;\r
+       }\r
+\r
+       /** Checks if the supplied ref name is a patchset ref */\r
+       private boolean isPatchsetRef(String refName) {\r
+               return !StringUtils.isEmpty(getPatchsetRef(refName));\r
+       }\r
+\r
+       /** Checks if the supplied ref name is a change ref */\r
+       private boolean isTicketRef(String refName) {\r
+               return refName.startsWith(Constants.R_TICKETS_PATCHSETS);\r
+       }\r
+\r
+       /** Extracts the integration branch from the ref name */\r
+       private String getIntegrationBranch(String refName) {\r
+               String patchsetRef = getPatchsetRef(refName);\r
+               String branch = refName.substring(patchsetRef.length());\r
+               if (branch.indexOf('%') > -1) {\r
+                       branch = branch.substring(0, branch.indexOf('%'));\r
+               }\r
+\r
+               String defaultBranch = "master";\r
+               try {\r
+                       defaultBranch = getRepository().getBranch();\r
+               } catch (Exception e) {\r
+                       LOGGER.error("failed to determine default branch for " + repository.name, e);\r
+               }\r
+\r
+               long ticketId = 0L;\r
+               try {\r
+                       ticketId = Long.parseLong(branch);\r
+               } catch (Exception e) {\r
+                       // not a number\r
+               }\r
+               if (ticketId > 0 || branch.equalsIgnoreCase("default") || branch.equalsIgnoreCase("new")) {\r
+                       return defaultBranch;\r
+               }\r
+               return branch;\r
+       }\r
+\r
+       /** Extracts the ticket id from the ref name */\r
+       private long getTicketId(String refName) {\r
+               if (refName.startsWith(Constants.R_FOR)) {\r
+                       String ref = refName.substring(Constants.R_FOR.length());\r
+                       if (ref.indexOf('%') > -1) {\r
+                               ref = ref.substring(0, ref.indexOf('%'));\r
+                       }\r
+                       try {\r
+                               return Long.parseLong(ref);\r
+                       } catch (Exception e) {\r
+                               // not a number\r
+                       }\r
+               } else if (refName.startsWith(Constants.R_TICKET) ||\r
+                               refName.startsWith(Constants.R_TICKETS_PATCHSETS)) {\r
+                       return PatchsetCommand.getTicketNumber(refName);\r
+               }\r
+               return 0L;\r
+       }\r
+\r
+       /** Returns true if the ref namespace exists */\r
+       private boolean hasRefNamespace(String ref) {\r
+               Map<String, Ref> blockingFors;\r
+               try {\r
+                       blockingFors = getRepository().getRefDatabase().getRefs(ref);\r
+               } catch (IOException err) {\r
+                       sendError("Cannot scan refs in {0}", repository.name);\r
+                       LOGGER.error("Error!", err);\r
+                       return true;\r
+               }\r
+               if (!blockingFors.isEmpty()) {\r
+                       sendError("{0} needs the following refs removed to receive patchsets: {1}",\r
+                                       repository.name, blockingFors.keySet());\r
+                       return true;\r
+               }\r
+               return false;\r
+       }\r
+\r
+       /** Removes change ref receive commands */\r
+       private List<ReceiveCommand> excludeTicketCommands(Collection<ReceiveCommand> commands) {\r
+               List<ReceiveCommand> filtered = new ArrayList<ReceiveCommand>();\r
+               for (ReceiveCommand cmd : commands) {\r
+                       if (!isTicketRef(cmd.getRefName())) {\r
+                               // this is not a ticket ref update\r
+                               filtered.add(cmd);\r
+                       }\r
+               }\r
+               return filtered;\r
+       }\r
+\r
+       /** Removes patchset receive commands for pre- and post- hook integrations */\r
+       private List<ReceiveCommand> excludePatchsetCommands(Collection<ReceiveCommand> commands) {\r
+               List<ReceiveCommand> filtered = new ArrayList<ReceiveCommand>();\r
+               for (ReceiveCommand cmd : commands) {\r
+                       if (!isPatchsetRef(cmd.getRefName())) {\r
+                               // this is a non-patchset ref update\r
+                               filtered.add(cmd);\r
+                       }\r
+               }\r
+               return filtered;\r
+       }\r
+\r
+       /**     Process receive commands EXCEPT for Patchset commands. */\r
+       @Override\r
+       public void onPreReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {\r
+               Collection<ReceiveCommand> filtered = excludePatchsetCommands(commands);\r
+               super.onPreReceive(rp, filtered);\r
+       }\r
+\r
+       /**     Process receive commands EXCEPT for Patchset commands. */\r
+       @Override\r
+       public void onPostReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {\r
+               Collection<ReceiveCommand> filtered = excludePatchsetCommands(commands);\r
+               super.onPostReceive(rp, filtered);\r
+\r
+               // send all queued ticket notifications after processing all patchsets\r
+               ticketNotifier.sendAll();\r
+       }\r
+\r
+       @Override\r
+       protected void validateCommands() {\r
+               // workaround for JGit's awful scoping choices\r
+               //\r
+               // set the patchset refs to OK to bypass checks in the super implementation\r
+               for (final ReceiveCommand cmd : filterCommands(Result.NOT_ATTEMPTED)) {\r
+                       if (isPatchsetRef(cmd.getRefName())) {\r
+                               if (cmd.getType() == ReceiveCommand.Type.CREATE) {\r
+                                       cmd.setResult(Result.OK);\r
+                               }\r
+                       }\r
+               }\r
+\r
+               super.validateCommands();\r
+       }\r
+\r
+       /** Execute commands to update references. */\r
+       @Override\r
+       protected void executeCommands() {\r
+               // workaround for JGit's awful scoping choices\r
+               //\r
+               // reset the patchset refs to NOT_ATTEMPTED (see validateCommands)\r
+               for (ReceiveCommand cmd : filterCommands(Result.OK)) {\r
+                       if (isPatchsetRef(cmd.getRefName())) {\r
+                               cmd.setResult(Result.NOT_ATTEMPTED);\r
+                       }\r
+               }\r
+\r
+               List<ReceiveCommand> toApply = filterCommands(Result.NOT_ATTEMPTED);\r
+               if (toApply.isEmpty()) {\r
+                       return;\r
+               }\r
+\r
+               ProgressMonitor updating = NullProgressMonitor.INSTANCE;\r
+               boolean sideBand = isCapabilityEnabled(CAPABILITY_SIDE_BAND_64K);\r
+               if (sideBand) {\r
+                       SideBandProgressMonitor pm = new SideBandProgressMonitor(msgOut);\r
+                       pm.setDelayStart(250, TimeUnit.MILLISECONDS);\r
+                       updating = pm;\r
+               }\r
+\r
+               BatchRefUpdate batch = getRepository().getRefDatabase().newBatchUpdate();\r
+               batch.setAllowNonFastForwards(isAllowNonFastForwards());\r
+               batch.setRefLogIdent(getRefLogIdent());\r
+               batch.setRefLogMessage("push", true);\r
+\r
+               ReceiveCommand patchsetRefCmd = null;\r
+               PatchsetCommand patchsetCmd = null;\r
+               for (ReceiveCommand cmd : toApply) {\r
+                       if (Result.NOT_ATTEMPTED != cmd.getResult()) {\r
+                               // Already rejected by the core receive process.\r
+                               continue;\r
+                       }\r
+\r
+                       if (isPatchsetRef(cmd.getRefName())) {\r
+                               if (ticketService == null) {\r
+                                       sendRejection(cmd, "Sorry, the ticket service is unavailable and can not accept patchsets at this time.");\r
+                                       continue;\r
+                               }\r
+\r
+                               if (!ticketService.isReady()) {\r
+                                       sendRejection(cmd, "Sorry, the ticket service can not accept patchsets at this time.");\r
+                                       continue;\r
+                               }\r
+\r
+                               if (UserModel.ANONYMOUS.equals(user)) {\r
+                                       // server allows anonymous pushes, but anonymous patchset\r
+                                       // contributions are prohibited by design\r
+                                       sendRejection(cmd, "Sorry, anonymous patchset contributions are prohibited.");\r
+                                       continue;\r
+                               }\r
+\r
+                               final Matcher m = NEW_PATCHSET.matcher(cmd.getRefName());\r
+                               if (m.matches()) {\r
+                                       // prohibit pushing directly to a patchset ref\r
+                                       long id = getTicketId(cmd.getRefName());\r
+                                       sendError("You may not directly push directly to a patchset ref!");\r
+                                       sendError("Instead, please push to one the following:");\r
+                                       sendError(" - {0}{1,number,0}", Constants.R_FOR, id);\r
+                                       sendError(" - {0}{1,number,0}", Constants.R_TICKET, id);\r
+                                       sendRejection(cmd, "protected ref");\r
+                                       continue;\r
+                               }\r
+\r
+                               if (hasRefNamespace(Constants.R_FOR)) {\r
+                                       // the refs/for/ namespace exists and it must not\r
+                                       LOGGER.error("{} already has refs in the {} namespace",\r
+                                                       repository.name, Constants.R_FOR);\r
+                                       sendRejection(cmd, "Sorry, a repository administrator will have to remove the {} namespace", Constants.R_FOR);\r
+                                       continue;\r
+                               }\r
+\r
+                               if (patchsetRefCmd != null) {\r
+                                       sendRejection(cmd, "You may only push one patchset at a time.");\r
+                                       continue;\r
+                               }\r
+\r
+                               // responsible verification\r
+                               String responsible = PatchsetCommand.getSingleOption(cmd, PatchsetCommand.RESPONSIBLE);\r
+                               if (!StringUtils.isEmpty(responsible)) {\r
+                                       UserModel assignee = gitblit.getUserModel(responsible);\r
+                                       if (assignee == null) {\r
+                                               // no account by this name\r
+                                               sendRejection(cmd, "{0} can not be assigned any tickets because there is no user account by that name", responsible);\r
+                                               continue;\r
+                                       } else if (!assignee.canPush(repository)) {\r
+                                               // account does not have RW permissions\r
+                                               sendRejection(cmd, "{0} ({1}) can not be assigned any tickets because the user does not have RW permissions for {2}",\r
+                                                               assignee.getDisplayName(), assignee.username, repository.name);\r
+                                               continue;\r
+                                       }\r
+                               }\r
+\r
+                               // milestone verification\r
+                               String milestone = PatchsetCommand.getSingleOption(cmd, PatchsetCommand.MILESTONE);\r
+                               if (!StringUtils.isEmpty(milestone)) {\r
+                                       TicketMilestone milestoneModel = ticketService.getMilestone(repository, milestone);\r
+                                       if (milestoneModel == null) {\r
+                                               // milestone does not exist\r
+                                               sendRejection(cmd, "Sorry, \"{0}\" is not a valid milestone!", milestone);\r
+                                               continue;\r
+                                       }\r
+                               }\r
+\r
+                               // watcher verification\r
+                               List<String> watchers = PatchsetCommand.getOptions(cmd, PatchsetCommand.WATCH);\r
+                               if (!ArrayUtils.isEmpty(watchers)) {\r
+                                       for (String watcher : watchers) {\r
+                                               UserModel user = gitblit.getUserModel(watcher);\r
+                                               if (user == null) {\r
+                                                       // watcher does not exist\r
+                                                       sendRejection(cmd, "Sorry, \"{0}\" is not a valid username for the watch list!", watcher);\r
+                                                       continue;\r
+                                               }\r
+                                       }\r
+                               }\r
+\r
+                               patchsetRefCmd = cmd;\r
+                               patchsetCmd = preparePatchset(cmd);\r
+                               if (patchsetCmd != null) {\r
+                                       batch.addCommand(patchsetCmd);\r
+                               }\r
+                               continue;\r
+                       }\r
+\r
+                       batch.addCommand(cmd);\r
+               }\r
+\r
+               if (!batch.getCommands().isEmpty()) {\r
+                       try {\r
+                               batch.execute(getRevWalk(), updating);\r
+                       } catch (IOException err) {\r
+                               for (ReceiveCommand cmd : toApply) {\r
+                                       if (cmd.getResult() == Result.NOT_ATTEMPTED) {\r
+                                               sendRejection(cmd, "lock error: {0}", err.getMessage());\r
+                                       }\r
+                               }\r
+                       }\r
+               }\r
+\r
+               //\r
+               // set the results into the patchset ref receive command\r
+               //\r
+               if (patchsetRefCmd != null && patchsetCmd != null) {\r
+                       if (!patchsetCmd.getResult().equals(Result.OK)) {\r
+                               // patchset command failed!\r
+                               LOGGER.error(patchsetCmd.getType() + " " + patchsetCmd.getRefName()\r
+                                               + " " + patchsetCmd.getResult());\r
+                               patchsetRefCmd.setResult(patchsetCmd.getResult(), patchsetCmd.getMessage());\r
+                       } else {\r
+                               // all patchset commands were applied\r
+                               patchsetRefCmd.setResult(Result.OK);\r
+\r
+                               // update the ticket branch ref\r
+                               RefUpdate ru = updateRef(patchsetCmd.getTicketBranch(), patchsetCmd.getNewId());\r
+                               updateReflog(ru);\r
+\r
+                               TicketModel ticket = processPatchset(patchsetCmd);\r
+                               if (ticket != null) {\r
+                                       ticketNotifier.queueMailing(ticket);\r
+                               }\r
+                       }\r
+               }\r
+\r
+               //\r
+               // if there are standard ref update receive commands that were\r
+               // successfully processed, process referenced tickets, if any\r
+               //\r
+               List<ReceiveCommand> allUpdates = ReceiveCommand.filter(batch.getCommands(), Result.OK);\r
+               List<ReceiveCommand> refUpdates = excludePatchsetCommands(allUpdates);\r
+               List<ReceiveCommand> stdUpdates = excludeTicketCommands(refUpdates);\r
+               if (!stdUpdates.isEmpty()) {\r
+                       int ticketsProcessed = 0;\r
+                       for (ReceiveCommand cmd : stdUpdates) {\r
+                               switch (cmd.getType()) {\r
+                               case CREATE:\r
+                               case UPDATE:\r
+                               case UPDATE_NONFASTFORWARD:\r
+                                       Collection<TicketModel> tickets = processMergedTickets(cmd);\r
+                                       ticketsProcessed += tickets.size();\r
+                                       for (TicketModel ticket : tickets) {\r
+                                               ticketNotifier.queueMailing(ticket);\r
+                                       }\r
+                                       break;\r
+                               default:\r
+                                       break;\r
+                               }\r
+                       }\r
+\r
+                       if (ticketsProcessed == 1) {\r
+                               sendInfo("1 ticket updated");\r
+                       } else if (ticketsProcessed > 1) {\r
+                               sendInfo("{0} tickets updated", ticketsProcessed);\r
+                       }\r
+               }\r
+\r
+               // reset the ticket caches for the repository\r
+               ticketService.resetCaches(repository);\r
+       }\r
+\r
+       /**\r
+        * Prepares a patchset command.\r
+        *\r
+        * @param cmd\r
+        * @return the patchset command\r
+        */\r
+       private PatchsetCommand preparePatchset(ReceiveCommand cmd) {\r
+               String branch = getIntegrationBranch(cmd.getRefName());\r
+               long number = getTicketId(cmd.getRefName());\r
+\r
+               TicketModel ticket = null;\r
+               if (number > 0 && ticketService.hasTicket(repository, number)) {\r
+                       ticket = ticketService.getTicket(repository, number);\r
+               }\r
+\r
+               if (ticket == null) {\r
+                       if (number > 0) {\r
+                               // requested ticket does not exist\r
+                               sendError("Sorry, {0} does not have ticket {1,number,0}!", repository.name, number);\r
+                               sendRejection(cmd, "Invalid ticket number");\r
+                               return null;\r
+                       }\r
+               } else {\r
+                       if (ticket.isMerged()) {\r
+                               // ticket already merged & resolved\r
+                               Change mergeChange = null;\r
+                               for (Change change : ticket.changes) {\r
+                                       if (change.isMerge()) {\r
+                                               mergeChange = change;\r
+                                               break;\r
+                                       }\r
+                               }\r
+                               sendError("Sorry, {0} already merged {1} from ticket {2,number,0} to {3}!",\r
+                                               mergeChange.author, mergeChange.patchset, number, ticket.mergeTo);\r
+                               sendRejection(cmd, "Ticket {0,number,0} already resolved", number);\r
+                               return null;\r
+                       } else if (!StringUtils.isEmpty(ticket.mergeTo)) {\r
+                               // ticket specifies integration branch\r
+                               branch = ticket.mergeTo;\r
+                       }\r
+               }\r
+\r
+               final int shortCommitIdLen = settings.getInteger(Keys.web.shortCommitIdLength, 6);\r
+               final String shortTipId = cmd.getNewId().getName().substring(0, shortCommitIdLen);\r
+               final RevCommit tipCommit = JGitUtils.getCommit(getRepository(), cmd.getNewId().getName());\r
+               final String forBranch = branch;\r
+               RevCommit mergeBase = null;\r
+               Ref forBranchRef = getAdvertisedRefs().get(Constants.R_HEADS + forBranch);\r
+               if (forBranchRef == null || forBranchRef.getObjectId() == null) {\r
+                       // unknown integration branch\r
+                       sendError("Sorry, there is no integration branch named ''{0}''.", forBranch);\r
+                       sendRejection(cmd, "Invalid integration branch specified");\r
+                       return null;\r
+               } else {\r
+                       // determine the merge base for the patchset on the integration branch\r
+                       String base = JGitUtils.getMergeBase(getRepository(), forBranchRef.getObjectId(), tipCommit.getId());\r
+                       if (StringUtils.isEmpty(base)) {\r
+                               sendError("");\r
+                               sendError("There is no common ancestry between {0} and {1}.", forBranch, shortTipId);\r
+                               sendError("Please reconsider your proposed integration branch, {0}.", forBranch);\r
+                               sendError("");\r
+                               sendRejection(cmd, "no merge base for patchset and {0}", forBranch);\r
+                               return null;\r
+                       }\r
+                       mergeBase = JGitUtils.getCommit(getRepository(), base);\r
+               }\r
+\r
+               // ensure that the patchset can be cleanly merged right now\r
+               MergeStatus status = JGitUtils.canMerge(getRepository(), tipCommit.getName(), forBranch);\r
+               switch (status) {\r
+               case ALREADY_MERGED:\r
+                       sendError("");\r
+                       sendError("You have already merged this patchset.", forBranch);\r
+                       sendError("");\r
+                       sendRejection(cmd, "everything up-to-date");\r
+                       return null;\r
+               case MERGEABLE:\r
+                       break;\r
+               default:\r
+                       if (ticket == null || requireCleanMerge) {\r
+                               sendError("");\r
+                               sendError("Your patchset can not be cleanly merged into {0}.", forBranch);\r
+                               sendError("Please rebase your patchset and push again.");\r
+                               sendError("NOTE:", number);\r
+                               sendError("You should push your rebase to refs/for/{0,number,0}", number);\r
+                               sendError("");\r
+                               sendError("  git push origin HEAD:refs/for/{0,number,0}", number);\r
+                               sendError("");\r
+                               sendRejection(cmd, "patchset not mergeable");\r
+                               return null;\r
+                       }\r
+               }\r
+\r
+               // check to see if this commit is already linked to a ticket\r
+               long id = identifyTicket(tipCommit, false);\r
+               if (id > 0) {\r
+                       sendError("{0} has already been pushed to ticket {1,number,0}.", shortTipId, id);\r
+                       sendRejection(cmd, "everything up-to-date");\r
+                       return null;\r
+               }\r
+\r
+               PatchsetCommand psCmd;\r
+               if (ticket == null) {\r
+                       /*\r
+                        *  NEW TICKET\r
+                        */\r
+                       Patchset patchset = newPatchset(null, mergeBase.getName(), tipCommit.getName());\r
+\r
+                       int minLength = 10;\r
+                       int maxLength = 100;\r
+                       String minTitle = MessageFormat.format("  minimum length of a title is {0} characters.", minLength);\r
+                       String maxTitle = MessageFormat.format("  maximum length of a title is {0} characters.", maxLength);\r
+\r
+                       if (patchset.commits > 1) {\r
+                               sendError("");\r
+                               sendError("To create a proposal ticket, please squash your commits and");\r
+                               sendError("provide a meaningful commit message with a short title &");\r
+                               sendError("an optional description/body.");\r
+                               sendError("");\r
+                               sendError(minTitle);\r
+                               sendError(maxTitle);\r
+                               sendError("");\r
+                               sendRejection(cmd, "please squash to one commit");\r
+                               return null;\r
+                       }\r
+\r
+                       // require a reasonable title/subject\r
+                       String title = tipCommit.getFullMessage().trim().split("\n")[0];\r
+                       if (title.length() < minLength) {\r
+                               // reject, title too short\r
+                               sendError("");\r
+                               sendError("Please supply a longer title in your commit message!");\r
+                               sendError("");\r
+                               sendError(minTitle);\r
+                               sendError(maxTitle);\r
+                               sendError("");\r
+                               sendRejection(cmd, "ticket title is too short [{0}/{1}]", title.length(), maxLength);\r
+                               return null;\r
+                       }\r
+                       if (title.length() > maxLength) {\r
+                               // reject, title too long\r
+                               sendError("");\r
+                               sendError("Please supply a more concise title in your commit message!");\r
+                               sendError("");\r
+                               sendError(minTitle);\r
+                               sendError(maxTitle);\r
+                               sendError("");\r
+                               sendRejection(cmd, "ticket title is too long [{0}/{1}]", title.length(), maxLength);\r
+                               return null;\r
+                       }\r
+\r
+                       // assign new id\r
+                       long ticketId = ticketService.assignNewId(repository);\r
+\r
+                       // create the patchset command\r
+                       psCmd = new PatchsetCommand(user.username, patchset);\r
+                       psCmd.newTicket(tipCommit, forBranch, ticketId, cmd.getRefName());\r
+               } else {\r
+                       /*\r
+                        *  EXISTING TICKET\r
+                        */\r
+                       Patchset patchset = newPatchset(ticket, mergeBase.getName(), tipCommit.getName());\r
+                       psCmd = new PatchsetCommand(user.username, patchset);\r
+                       psCmd.updateTicket(tipCommit, forBranch, ticket, cmd.getRefName());\r
+               }\r
+\r
+               // confirm user can push the patchset\r
+               boolean pushPermitted = ticket == null\r
+                               || !ticket.hasPatchsets()\r
+                               || ticket.isAuthor(user.username)\r
+                               || ticket.isPatchsetAuthor(user.username)\r
+                               || ticket.isResponsible(user.username)\r
+                               || user.canPush(repository);\r
+\r
+               switch (psCmd.getPatchsetType()) {\r
+               case Proposal:\r
+                       // proposals (first patchset) are always acceptable\r
+                       break;\r
+               case FastForward:\r
+                       // patchset updates must be permitted\r
+                       if (!pushPermitted) {\r
+                               // reject\r
+                               sendError("");\r
+                               sendError("To push a patchset to this ticket one of the following must be true:");\r
+                               sendError("  1. you created the ticket");\r
+                               sendError("  2. you created the first patchset");\r
+                               sendError("  3. you are specified as responsible for the ticket");\r
+                               sendError("  4. you are listed as a reviewer for the ticket");\r
+                               sendError("  5. you have push (RW) permission to {0}", repository.name);\r
+                               sendError("");\r
+                               sendRejection(cmd, "not permitted to push to ticket {0,number,0}", ticket.number);\r
+                               return null;\r
+                       }\r
+                       break;\r
+               default:\r
+                       // non-fast-forward push\r
+                       if (!pushPermitted) {\r
+                               // reject\r
+                               sendRejection(cmd, "non-fast-forward ({0})", psCmd.getPatchsetType());\r
+                               return null;\r
+                       }\r
+                       break;\r
+               }\r
+               return psCmd;\r
+       }\r
+\r
+       /**\r
+        * Creates or updates an ticket with the specified patchset.\r
+        *\r
+        * @param cmd\r
+        * @return a ticket if the creation or update was successful\r
+        */\r
+       private TicketModel processPatchset(PatchsetCommand cmd) {\r
+               Change change = cmd.getChange();\r
+\r
+               if (cmd.isNewTicket()) {\r
+                       // create the ticket object\r
+                       TicketModel ticket = ticketService.createTicket(repository, cmd.getTicketId(), change);\r
+                       if (ticket != null) {\r
+                               sendInfo("");\r
+                               sendHeader("#{0,number,0}: {1}", ticket.number, StringUtils.trimString(ticket.title, Constants.LEN_SHORTLOG));\r
+                               sendInfo("created proposal ticket from patchset");\r
+                               sendInfo(ticketService.getTicketUrl(ticket));\r
+                               sendInfo("");\r
+\r
+                               // log the new patch ref\r
+                               RefLogUtils.updateRefLog(user, getRepository(),\r
+                                               Arrays.asList(new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), cmd.getRefName())));\r
+\r
+                               return ticket;\r
+                       } else {\r
+                               sendError("FAILED to create ticket");\r
+                       }\r
+               } else {\r
+                       // update an existing ticket\r
+                       TicketModel ticket = ticketService.updateTicket(repository, cmd.getTicketId(), change);\r
+                       if (ticket != null) {\r
+                               sendInfo("");\r
+                               sendHeader("#{0,number,0}: {1}", ticket.number, StringUtils.trimString(ticket.title, Constants.LEN_SHORTLOG));\r
+                               if (change.patchset.rev == 1) {\r
+                                       // new patchset\r
+                                       sendInfo("uploaded patchset {0} ({1})", change.patchset.number, change.patchset.type.toString());\r
+                               } else {\r
+                                       // updated patchset\r
+                                       sendInfo("added {0} {1} to patchset {2}",\r
+                                                       change.patchset.added,\r
+                                                       change.patchset.added == 1 ? "commit" : "commits",\r
+                                                       change.patchset.number);\r
+                               }\r
+                               sendInfo(ticketService.getTicketUrl(ticket));\r
+                               sendInfo("");\r
+\r
+                               // log the new patchset ref\r
+                               RefLogUtils.updateRefLog(user, getRepository(),\r
+                                       Arrays.asList(new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), cmd.getRefName())));\r
+\r
+                               // return the updated ticket\r
+                               return ticket;\r
+                       } else {\r
+                               sendError("FAILED to upload {0} for ticket {1,number,0}", change.patchset, cmd.getTicketId());\r
+                       }\r
+               }\r
+\r
+               return null;\r
+       }\r
+\r
+       /**\r
+        * Automatically closes open tickets that have been merged to their integration\r
+        * branch by a client.\r
+        *\r
+        * @param cmd\r
+        */\r
+       private Collection<TicketModel> processMergedTickets(ReceiveCommand cmd) {\r
+               Map<Long, TicketModel> mergedTickets = new LinkedHashMap<Long, TicketModel>();\r
+               final RevWalk rw = getRevWalk();\r
+               try {\r
+                       rw.reset();\r
+                       rw.markStart(rw.parseCommit(cmd.getNewId()));\r
+                       if (!ObjectId.zeroId().equals(cmd.getOldId())) {\r
+                               rw.markUninteresting(rw.parseCommit(cmd.getOldId()));\r
+                       }\r
+\r
+                       RevCommit c;\r
+                       while ((c = rw.next()) != null) {\r
+                               rw.parseBody(c);\r
+                               long ticketNumber = identifyTicket(c, true);\r
+                               if (ticketNumber == 0L || mergedTickets.containsKey(ticketNumber)) {\r
+                                       continue;\r
+                               }\r
+\r
+                               TicketModel ticket = ticketService.getTicket(repository, ticketNumber);\r
+                               String integrationBranch;\r
+                               if (StringUtils.isEmpty(ticket.mergeTo)) {\r
+                                       // unspecified integration branch\r
+                                       integrationBranch = null;\r
+                               } else {\r
+                                       // specified integration branch\r
+                                       integrationBranch = Constants.R_HEADS + ticket.mergeTo;\r
+                               }\r
+\r
+                               // ticket must be open and, if specified, the ref must match the integration branch\r
+                               if (ticket.isClosed() || (integrationBranch != null && !integrationBranch.equals(cmd.getRefName()))) {\r
+                                       continue;\r
+                               }\r
+\r
+                               String baseRef = PatchsetCommand.getBasePatchsetBranch(ticket.number);\r
+                               boolean knownPatchset = false;\r
+                               Set<Ref> refs = getRepository().getAllRefsByPeeledObjectId().get(c.getId());\r
+                               if (refs != null) {\r
+                                       for (Ref ref : refs) {\r
+                                               if (ref.getName().startsWith(baseRef)) {\r
+                                                       knownPatchset = true;\r
+                                                       break;\r
+                                               }\r
+                                       }\r
+                               }\r
+\r
+                               String mergeSha = c.getName();\r
+                               String mergeTo = Repository.shortenRefName(cmd.getRefName());\r
+                               Change change;\r
+                               Patchset patchset;\r
+                               if (knownPatchset) {\r
+                                       // identify merged patchset by the patchset tip\r
+                                       patchset = null;\r
+                                       for (Patchset ps : ticket.getPatchsets()) {\r
+                                               if (ps.tip.equals(mergeSha)) {\r
+                                                       patchset = ps;\r
+                                                       break;\r
+                                               }\r
+                                       }\r
+\r
+                                       if (patchset == null) {\r
+                                               // should not happen - unless ticket has been hacked\r
+                                               sendError("Failed to find the patchset for {0} in ticket {1,number,0}?!",\r
+                                                               mergeSha, ticket.number);\r
+                                               continue;\r
+                                       }\r
+\r
+                                       // create a new change\r
+                                       change = new Change(user.username);\r
+                               } else {\r
+                                       // new patchset pushed by user\r
+                                       String base = cmd.getOldId().getName();\r
+                                       patchset = newPatchset(ticket, base, mergeSha);\r
+                                       PatchsetCommand psCmd = new PatchsetCommand(user.username, patchset);\r
+                                       psCmd.updateTicket(c, mergeTo, ticket, null);\r
+\r
+                                       // create a ticket patchset ref\r
+                                       updateRef(psCmd.getPatchsetBranch(), c.getId());\r
+                                       RefUpdate ru = updateRef(psCmd.getTicketBranch(), c.getId());\r
+                                       updateReflog(ru);\r
+\r
+                                       // create a change from the patchset command\r
+                                       change = psCmd.getChange();\r
+                               }\r
+\r
+                               // set the common change data about the merge\r
+                               change.setField(Field.status, Status.Merged);\r
+                               change.setField(Field.mergeSha, mergeSha);\r
+                               change.setField(Field.mergeTo, mergeTo);\r
+\r
+                               if (StringUtils.isEmpty(ticket.responsible)) {\r
+                                       // unassigned tickets are assigned to the closer\r
+                                       change.setField(Field.responsible, user.username);\r
+                               }\r
+\r
+                               ticket = ticketService.updateTicket(repository, ticket.number, change);\r
+                               if (ticket != null) {\r
+                                       sendInfo("");\r
+                                       sendHeader("#{0,number,0}: {1}", ticket.number, StringUtils.trimString(ticket.title, Constants.LEN_SHORTLOG));\r
+                                       sendInfo("closed by push of {0} to {1}", patchset, mergeTo);\r
+                                       sendInfo(ticketService.getTicketUrl(ticket));\r
+                                       sendInfo("");\r
+                                       mergedTickets.put(ticket.number, ticket);\r
+                               } else {\r
+                                       String shortid = mergeSha.substring(0, settings.getInteger(Keys.web.shortCommitIdLength, 6));\r
+                                       sendError("FAILED to close ticket {0,number,0} by push of {1}", ticketNumber, shortid);\r
+                               }\r
+                       }\r
+               } catch (IOException e) {\r
+                       LOGGER.error("Can't scan for changes to close", e);\r
+               } finally {\r
+                       rw.reset();\r
+               }\r
+\r
+               return mergedTickets.values();\r
+       }\r
+\r
+       /**\r
+        * Try to identify a ticket id from the commit.\r
+        *\r
+        * @param commit\r
+        * @param parseMessage\r
+        * @return a ticket id or 0\r
+        */\r
+       private long identifyTicket(RevCommit commit, boolean parseMessage) {\r
+               // try lookup by change ref\r
+               Map<AnyObjectId, Set<Ref>> map = getRepository().getAllRefsByPeeledObjectId();\r
+               Set<Ref> refs = map.get(commit.getId());\r
+               if (!ArrayUtils.isEmpty(refs)) {\r
+                       for (Ref ref : refs) {\r
+                               long number = PatchsetCommand.getTicketNumber(ref.getName());\r
+                               if (number > 0) {\r
+                                       return number;\r
+                               }\r
+                       }\r
+               }\r
+\r
+               if (parseMessage) {\r
+                       // parse commit message looking for fixes/closes #n\r
+                       Pattern p = Pattern.compile("(?:fixes|closes)[\\s-]+#?(\\d+)", Pattern.CASE_INSENSITIVE);\r
+                       Matcher m = p.matcher(commit.getFullMessage());\r
+                       while (m.find()) {\r
+                               String val = m.group();\r
+                               return Long.parseLong(val);\r
+                       }\r
+               }\r
+               return 0L;\r
+       }\r
+\r
+       private int countCommits(String baseId, String tipId) {\r
+               int count = 0;\r
+               RevWalk walk = getRevWalk();\r
+               walk.reset();\r
+               walk.sort(RevSort.TOPO);\r
+               walk.sort(RevSort.REVERSE, true);\r
+               try {\r
+                       RevCommit tip = walk.parseCommit(getRepository().resolve(tipId));\r
+                       RevCommit base = walk.parseCommit(getRepository().resolve(baseId));\r
+                       walk.markStart(tip);\r
+                       walk.markUninteresting(base);\r
+                       for (;;) {\r
+                               RevCommit c = walk.next();\r
+                               if (c == null) {\r
+                                       break;\r
+                               }\r
+                               count++;\r
+                       }\r
+               } catch (IOException e) {\r
+                       // Should never happen, the core receive process would have\r
+                       // identified the missing object earlier before we got control.\r
+                       LOGGER.error("failed to get commit count", e);\r
+                       return 0;\r
+               } finally {\r
+                       walk.release();\r
+               }\r
+               return count;\r
+       }\r
+\r
+       /**\r
+        * Creates a new patchset with metadata.\r
+        *\r
+        * @param ticket\r
+        * @param mergeBase\r
+        * @param tip\r
+        */\r
+       private Patchset newPatchset(TicketModel ticket, String mergeBase, String tip) {\r
+               int totalCommits = countCommits(mergeBase, tip);\r
+\r
+               Patchset newPatchset = new Patchset();\r
+               newPatchset.tip = tip;\r
+               newPatchset.base = mergeBase;\r
+               newPatchset.commits = totalCommits;\r
+\r
+               Patchset currPatchset = ticket == null ? null : ticket.getCurrentPatchset();\r
+               if (currPatchset == null) {\r
+                       /*\r
+                        * PROPOSAL PATCHSET\r
+                        * patchset 1, rev 1\r
+                        */\r
+                       newPatchset.number = 1;\r
+                       newPatchset.rev = 1;\r
+                       newPatchset.type = PatchsetType.Proposal;\r
+\r
+                       // diffstat from merge base\r
+                       DiffStat diffStat = DiffUtils.getDiffStat(getRepository(), mergeBase, tip);\r
+                       newPatchset.insertions = diffStat.getInsertions();\r
+                       newPatchset.deletions = diffStat.getDeletions();\r
+               } else {\r
+                       /*\r
+                        * PATCHSET UPDATE\r
+                        */\r
+                       int added = totalCommits - currPatchset.commits;\r
+                       boolean ff = JGitUtils.isMergedInto(getRepository(), currPatchset.tip, tip);\r
+                       boolean squash = added < 0;\r
+                       boolean rebase = !currPatchset.base.equals(mergeBase);\r
+\r
+                       // determine type, number and rev of the patchset\r
+                       if (ff) {\r
+                               /*\r
+                                * FAST-FORWARD\r
+                                * patchset number preserved, rev incremented\r
+                                */\r
+\r
+                               boolean merged = JGitUtils.isMergedInto(getRepository(), currPatchset.tip, ticket.mergeTo);\r
+                               if (merged) {\r
+                                       // current patchset was already merged\r
+                                       // new patchset, mark as rebase\r
+                                       newPatchset.type = PatchsetType.Rebase;\r
+                                       newPatchset.number = currPatchset.number + 1;\r
+                                       newPatchset.rev = 1;\r
+\r
+                                       // diffstat from parent\r
+                                       DiffStat diffStat = DiffUtils.getDiffStat(getRepository(), mergeBase, tip);\r
+                                       newPatchset.insertions = diffStat.getInsertions();\r
+                                       newPatchset.deletions = diffStat.getDeletions();\r
+                               } else {\r
+                                       // FF update to patchset\r
+                                       newPatchset.type = PatchsetType.FastForward;\r
+                                       newPatchset.number = currPatchset.number;\r
+                                       newPatchset.rev = currPatchset.rev + 1;\r
+                                       newPatchset.parent = currPatchset.tip;\r
+\r
+                                       // diffstat from parent\r
+                                       DiffStat diffStat = DiffUtils.getDiffStat(getRepository(), currPatchset.tip, tip);\r
+                                       newPatchset.insertions = diffStat.getInsertions();\r
+                                       newPatchset.deletions = diffStat.getDeletions();\r
+                               }\r
+                       } else {\r
+                               /*\r
+                                * NON-FAST-FORWARD\r
+                                * new patchset, rev 1\r
+                                */\r
+                               if (rebase && squash) {\r
+                                       newPatchset.type = PatchsetType.Rebase_Squash;\r
+                                       newPatchset.number = currPatchset.number + 1;\r
+                                       newPatchset.rev = 1;\r
+                               } else if (squash) {\r
+                                       newPatchset.type = PatchsetType.Squash;\r
+                                       newPatchset.number = currPatchset.number + 1;\r
+                                       newPatchset.rev = 1;\r
+                               } else if (rebase) {\r
+                                       newPatchset.type = PatchsetType.Rebase;\r
+                                       newPatchset.number = currPatchset.number + 1;\r
+                                       newPatchset.rev = 1;\r
+                               } else {\r
+                                       newPatchset.type = PatchsetType.Amend;\r
+                                       newPatchset.number = currPatchset.number + 1;\r
+                                       newPatchset.rev = 1;\r
+                               }\r
+\r
+                               // diffstat from merge base\r
+                               DiffStat diffStat = DiffUtils.getDiffStat(getRepository(), mergeBase, tip);\r
+                               newPatchset.insertions = diffStat.getInsertions();\r
+                               newPatchset.deletions = diffStat.getDeletions();\r
+                       }\r
+\r
+                       if (added > 0) {\r
+                               // ignore squash (negative add)\r
+                               newPatchset.added = added;\r
+                       }\r
+               }\r
+\r
+               return newPatchset;\r
+       }\r
+\r
+       private RefUpdate updateRef(String ref, ObjectId newId) {\r
+               ObjectId ticketRefId = ObjectId.zeroId();\r
+               try {\r
+                       ticketRefId = getRepository().resolve(ref);\r
+               } catch (Exception e) {\r
+                       // ignore\r
+               }\r
+\r
+               try {\r
+                       RefUpdate ru = getRepository().updateRef(ref,  false);\r
+                       ru.setRefLogIdent(getRefLogIdent());\r
+                       ru.setForceUpdate(true);\r
+                       ru.setExpectedOldObjectId(ticketRefId);\r
+                       ru.setNewObjectId(newId);\r
+                       RefUpdate.Result result = ru.update(getRevWalk());\r
+                       if (result == RefUpdate.Result.LOCK_FAILURE) {\r
+                               sendError("Failed to obtain lock when updating {0}:{1}", repository.name, ref);\r
+                               sendError("Perhaps an administrator should remove {0}/{1}.lock?", getRepository().getDirectory(), ref);\r
+                               return null;\r
+                       }\r
+                       return ru;\r
+               } catch (IOException e) {\r
+                       LOGGER.error("failed to update ref " + ref, e);\r
+                       sendError("There was an error updating ref {0}:{1}", repository.name, ref);\r
+               }\r
+               return null;\r
+       }\r
+\r
+       private void updateReflog(RefUpdate ru) {\r
+               if (ru == null) {\r
+                       return;\r
+               }\r
+\r
+               ReceiveCommand.Type type = null;\r
+               switch (ru.getResult()) {\r
+               case NEW:\r
+                       type = Type.CREATE;\r
+                       break;\r
+               case FAST_FORWARD:\r
+                       type = Type.UPDATE;\r
+                       break;\r
+               case FORCED:\r
+                       type = Type.UPDATE_NONFASTFORWARD;\r
+                       break;\r
+               default:\r
+                       LOGGER.error(MessageFormat.format("unexpected ref update type {0} for {1}",\r
+                                       ru.getResult(), ru.getName()));\r
+                       return;\r
+               }\r
+               ReceiveCommand cmd = new ReceiveCommand(ru.getOldObjectId(), ru.getNewObjectId(), ru.getName(), type);\r
+               RefLogUtils.updateRefLog(user, getRepository(), Arrays.asList(cmd));\r
+       }\r
+\r
+       /**\r
+        * Merge the specified patchset to the integration branch.\r
+        *\r
+        * @param ticket\r
+        * @param patchset\r
+        * @return true, if successful\r
+        */\r
+       public MergeStatus merge(TicketModel ticket) {\r
+               PersonIdent committer = new PersonIdent(user.getDisplayName(), StringUtils.isEmpty(user.emailAddress) ? (user.username + "@gitblit") : user.emailAddress);\r
+               Patchset patchset = ticket.getCurrentPatchset();\r
+               String message = MessageFormat.format("Merged #{0,number,0} \"{1}\"", ticket.number, ticket.title);\r
+               Ref oldRef = null;\r
+               try {\r
+                       oldRef = getRepository().getRef(ticket.mergeTo);\r
+               } catch (IOException e) {\r
+                       LOGGER.error("failed to get ref for " + ticket.mergeTo, e);\r
+               }\r
+               MergeResult mergeResult = JGitUtils.merge(\r
+                               getRepository(),\r
+                               patchset.tip,\r
+                               ticket.mergeTo,\r
+                               committer,\r
+                               message);\r
+\r
+               if (StringUtils.isEmpty(mergeResult.sha)) {\r
+                       LOGGER.error("FAILED to merge {} to {} ({})", new Object [] { patchset, ticket.mergeTo, mergeResult.status.name() });\r
+                       return mergeResult.status;\r
+               }\r
+               Change change = new Change(user.username);\r
+               change.setField(Field.status, Status.Merged);\r
+               change.setField(Field.mergeSha, mergeResult.sha);\r
+               change.setField(Field.mergeTo, ticket.mergeTo);\r
+\r
+               if (StringUtils.isEmpty(ticket.responsible)) {\r
+                       // unassigned tickets are assigned to the closer\r
+                       change.setField(Field.responsible, user.username);\r
+               }\r
+\r
+               long ticketId = ticket.number;\r
+               ticket = ticketService.updateTicket(repository, ticket.number, change);\r
+               if (ticket != null) {\r
+                       ticketNotifier.queueMailing(ticket);\r
+\r
+                       // update the reflog with the merge\r
+                       if (oldRef != null) {\r
+                               ReceiveCommand cmd = new ReceiveCommand(oldRef.getObjectId(),\r
+                                               ObjectId.fromString(mergeResult.sha), oldRef.getName());\r
+                               RefLogUtils.updateRefLog(user, getRepository(), Arrays.asList(cmd));\r
+                       }\r
+                       return mergeResult.status;\r
+               } else {\r
+                       LOGGER.error("FAILED to resolve ticket {} by merge from web ui", ticketId);\r
+               }\r
+               return mergeResult.status;\r
+       }\r
+\r
+       public void sendAll() {\r
+               ticketNotifier.sendAll();\r
+       }\r
+}
index 6eb6023640fa7f2109d37886a22e09dae2ed8b99..b27d650dd0b6cf4d11d9b0c745bc64da02249477 100644 (file)
@@ -62,6 +62,7 @@ import com.gitblit.models.ServerStatus;
 import com.gitblit.models.SettingModel;
 import com.gitblit.models.TeamModel;
 import com.gitblit.models.UserModel;
+import com.gitblit.tickets.ITicketService;
 import com.gitblit.utils.ArrayUtils;
 import com.gitblit.utils.HttpUtils;
 import com.gitblit.utils.JGitUtils;
@@ -483,6 +484,15 @@ public class GitblitManager implements IGitblit {
                }
        }
 
+       /**
+        * Throws an exception if trying to get a ticket service.
+        *
+        */
+       @Override
+       public ITicketService getTicketService() {
+               throw new RuntimeException("This class does not have a ticket service!");
+       }
+
        /*
         * ISTOREDSETTINGS
         *
index aa09122672961ea7352333a9cbfbcd3c3b34f477..50210e9d0ce7a8bb842ea8824a836d517f6f2226 100644 (file)
@@ -26,6 +26,7 @@ import com.gitblit.models.RepositoryModel;
 import com.gitblit.models.RepositoryUrl;
 import com.gitblit.models.TeamModel;
 import com.gitblit.models.UserModel;
+import com.gitblit.tickets.ITicketService;
 
 public interface IGitblit extends IManager,
                                                                        IRuntimeManager,
@@ -101,4 +102,11 @@ public interface IGitblit extends IManager,
         */
        Collection<GitClientApplication> getClientApplications();
 
+       /**
+        * Returns the ticket service.
+        *
+        * @return a ticket service
+        */
+       ITicketService getTicketService();
+
 }
\ No newline at end of file
index e412deba35fb4931b7958e248be63979d339ac66..1e917984e5c481ae8a903a1ddf4938d2c8481f45 100644 (file)
@@ -801,6 +801,9 @@ public class RepositoryManager implements IRepositoryManager {
                        model.description = getConfig(config, "description", "");
                        model.originRepository = getConfig(config, "originRepository", null);
                        model.addOwners(ArrayUtils.fromString(getConfig(config, "owner", "")));
+                       model.acceptNewPatchsets = getConfig(config, "acceptNewPatchsets", true);
+                       model.acceptNewTickets = getConfig(config, "acceptNewTickets", true);
+                       model.requireApproval = getConfig(config, "requireApproval", settings.getBoolean(Keys.tickets.requireApproval, false));
                        model.useIncrementalPushTags = getConfig(config, "useIncrementalPushTags", false);
                        model.incrementalPushTagPrefix = getConfig(config, "incrementalPushTagPrefix", null);
                        model.allowForks = getConfig(config, "allowForks", true);
@@ -1406,6 +1409,15 @@ public class RepositoryManager implements IRepositoryManager {
                config.setString(Constants.CONFIG_GITBLIT, null, "description", repository.description);
                config.setString(Constants.CONFIG_GITBLIT, null, "originRepository", repository.originRepository);
                config.setString(Constants.CONFIG_GITBLIT, null, "owner", ArrayUtils.toString(repository.owners));
+               config.setBoolean(Constants.CONFIG_GITBLIT, null, "acceptNewPatchsets", repository.acceptNewPatchsets);
+               config.setBoolean(Constants.CONFIG_GITBLIT, null, "acceptNewTickets", repository.acceptNewTickets);
+               if (settings.getBoolean(Keys.tickets.requireApproval, false) == repository.requireApproval) {
+                       // use default
+                       config.unset(Constants.CONFIG_GITBLIT, null, "requireApproval");
+               } else {
+                       // override default
+                       config.setBoolean(Constants.CONFIG_GITBLIT, null, "requireApproval", repository.requireApproval);
+               }
                config.setBoolean(Constants.CONFIG_GITBLIT, null, "useIncrementalPushTags", repository.useIncrementalPushTags);
                if (StringUtils.isEmpty(repository.incrementalPushTagPrefix) ||
                                repository.incrementalPushTagPrefix.equals(settings.getString(Keys.git.defaultIncrementalPushTagPrefix, "r"))) {
index b76e9bc692ee4155ae1dabcca2822034a2e7a946..5bd2ec038a2f68a70276cbdd848905a3c3c79d5e 100644 (file)
@@ -85,6 +85,9 @@ public class RepositoryModel implements Serializable, Comparable<RepositoryModel
        public int maxActivityCommits;\r
        public List<String> metricAuthorExclusions;\r
        public CommitMessageRenderer commitMessageRenderer;\r
+       public boolean acceptNewPatchsets;\r
+       public boolean acceptNewTickets;\r
+       public boolean requireApproval;
 \r
        public transient boolean isCollectingGarbage;\r
        public Date lastGC;\r
@@ -105,6 +108,8 @@ public class RepositoryModel implements Serializable, Comparable<RepositoryModel
                this.projectPath = StringUtils.getFirstPathElement(name);\r
                this.owners = new ArrayList<String>();\r
                this.isBare = true;\r
+               this.acceptNewTickets = true;\r
+               this.acceptNewPatchsets = true;\r
 \r
                addOwner(owner);\r
        }\r
@@ -140,6 +145,10 @@ public class RepositoryModel implements Serializable, Comparable<RepositoryModel
                displayName = null;\r
        }\r
 \r
+       public String getRID() {\r
+               return StringUtils.getSHA1(name);\r
+       }\r
+\r
        @Override\r
        public int hashCode() {\r
                return name.hashCode();\r
@@ -209,6 +218,8 @@ public class RepositoryModel implements Serializable, Comparable<RepositoryModel
                clone.federationStrategy = federationStrategy;\r
                clone.showRemoteBranches = false;\r
                clone.allowForks = false;\r
+               clone.acceptNewPatchsets = false;\r
+               clone.acceptNewTickets = false;
                clone.skipSizeCalculation = skipSizeCalculation;\r
                clone.skipSummaryMetrics = skipSummaryMetrics;\r
                clone.sparkleshareId = sparkleshareId;\r
diff --git a/src/main/java/com/gitblit/models/TicketModel.java b/src/main/java/com/gitblit/models/TicketModel.java
new file mode 100644 (file)
index 0000000..1ff55dd
--- /dev/null
@@ -0,0 +1,1286 @@
+/*
+ * Copyright 2014 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.models;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.Serializable;
+import java.io.UnsupportedEncodingException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.eclipse.jgit.util.RelativeDateFormatter;
+
+/**
+ * The Gitblit Ticket model, its component classes, and enums.
+ *
+ * @author James Moger
+ *
+ */
+public class TicketModel implements Serializable, Comparable<TicketModel> {
+
+       private static final long serialVersionUID = 1L;
+
+       public String project;
+
+       public String repository;
+
+       public long number;
+
+       public Date created;
+
+       public String createdBy;
+
+       public Date updated;
+
+       public String updatedBy;
+
+       public String title;
+
+       public String body;
+
+       public String topic;
+
+       public Type type;
+
+       public Status status;
+
+       public String responsible;
+
+       public String milestone;
+
+       public String mergeSha;
+
+       public String mergeTo;
+
+       public List<Change> changes;
+
+       public Integer insertions;
+
+       public Integer deletions;
+
+       /**
+        * Builds an effective ticket from the collection of changes.  A change may
+        * Add or Subtract information from a ticket, but the collection of changes
+        * is only additive.
+        *
+        * @param changes
+        * @return the effective ticket
+        */
+       public static TicketModel buildTicket(Collection<Change> changes) {
+               TicketModel ticket;
+               List<Change> effectiveChanges = new ArrayList<Change>();
+               Map<String, Change> comments = new HashMap<String, Change>();
+               for (Change change : changes) {
+                       if (change.comment != null) {
+                               if (comments.containsKey(change.comment.id)) {
+                                       Change original = comments.get(change.comment.id);
+                                       Change clone = copy(original);
+                                       clone.comment.text = change.comment.text;
+                                       clone.comment.deleted = change.comment.deleted;
+                                       int idx = effectiveChanges.indexOf(original);
+                                       effectiveChanges.remove(original);
+                                       effectiveChanges.add(idx, clone);
+                                       comments.put(clone.comment.id, clone);
+                               } else {
+                                       effectiveChanges.add(change);
+                                       comments.put(change.comment.id, change);
+                               }
+                       } else {
+                               effectiveChanges.add(change);
+                       }
+               }
+
+               // effective ticket
+               ticket = new TicketModel();
+               for (Change change : effectiveChanges) {
+                       if (!change.hasComment()) {
+                               // ensure we do not include a deleted comment
+                               change.comment = null;
+                       }
+                       ticket.applyChange(change);
+               }
+               return ticket;
+       }
+
+       public TicketModel() {
+               // the first applied change set the date appropriately
+               created = new Date(0);
+               changes = new ArrayList<Change>();
+               status = Status.New;
+               type = Type.defaultType;
+       }
+
+       public boolean isOpen() {
+               return !status.isClosed();
+       }
+
+       public boolean isClosed() {
+               return status.isClosed();
+       }
+
+       public boolean isMerged() {
+               return isClosed() && !isEmpty(mergeSha);
+       }
+
+       public boolean isProposal() {
+               return Type.Proposal == type;
+       }
+
+       public boolean isBug() {
+               return Type.Bug == type;
+       }
+
+       public Date getLastUpdated() {
+               return updated == null ? created : updated;
+       }
+
+       public boolean hasPatchsets() {
+               return getPatchsets().size() > 0;
+       }
+
+       /**
+        * Returns true if multiple participants are involved in discussing a ticket.
+        * The ticket creator is excluded from this determination because a
+        * discussion requires more than one participant.
+        *
+        * @return true if this ticket has a discussion
+        */
+       public boolean hasDiscussion() {
+               for (Change change : getComments()) {
+                       if (!change.author.equals(createdBy)) {
+                               return true;
+                       }
+               }
+               return false;
+       }
+
+       /**
+        * Returns the list of changes with comments.
+        *
+        * @return
+        */
+       public List<Change> getComments() {
+               List<Change> list = new ArrayList<Change>();
+               for (Change change : changes) {
+                       if (change.hasComment()) {
+                               list.add(change);
+                       }
+               }
+               return list;
+       }
+
+       /**
+        * Returns the list of participants for the ticket.
+        *
+        * @return the list of participants
+        */
+       public List<String> getParticipants() {
+               Set<String> set = new LinkedHashSet<String>();
+               for (Change change : changes) {
+                       if (change.isParticipantChange()) {
+                               set.add(change.author);
+                       }
+               }
+               if (responsible != null && responsible.length() > 0) {
+                       set.add(responsible);
+               }
+               return new ArrayList<String>(set);
+       }
+
+       public boolean hasLabel(String label) {
+               return getLabels().contains(label);
+       }
+
+       public List<String> getLabels() {
+               return getList(Field.labels);
+       }
+
+       public boolean isResponsible(String username) {
+               return username.equals(responsible);
+       }
+
+       public boolean isAuthor(String username) {
+               return username.equals(createdBy);
+       }
+
+       public boolean isReviewer(String username) {
+               return getReviewers().contains(username);
+       }
+
+       public List<String> getReviewers() {
+               return getList(Field.reviewers);
+       }
+
+       public boolean isWatching(String username) {
+               return getWatchers().contains(username);
+       }
+
+       public List<String> getWatchers() {
+               return getList(Field.watchers);
+       }
+
+       public boolean isVoter(String username) {
+               return getVoters().contains(username);
+       }
+
+       public List<String> getVoters() {
+               return getList(Field.voters);
+       }
+
+       public List<String> getMentions() {
+               return getList(Field.mentions);
+       }
+
+       protected List<String> getList(Field field) {
+               Set<String> set = new TreeSet<String>();
+               for (Change change : changes) {
+                       if (change.hasField(field)) {
+                               String values = change.getString(field);
+                               for (String value : values.split(",")) {
+                                       switch (value.charAt(0)) {
+                                       case '+':
+                                               set.add(value.substring(1));
+                                               break;
+                                       case '-':
+                                               set.remove(value.substring(1));
+                                               break;
+                                       default:
+                                               set.add(value);
+                                       }
+                               }
+                       }
+               }
+               if (!set.isEmpty()) {
+                       return new ArrayList<String>(set);
+               }
+               return Collections.emptyList();
+       }
+
+       public Attachment getAttachment(String name) {
+               Attachment attachment = null;
+               for (Change change : changes) {
+                       if (change.hasAttachments()) {
+                               Attachment a = change.getAttachment(name);
+                               if (a != null) {
+                                       attachment = a;
+                               }
+                       }
+               }
+               return attachment;
+       }
+
+       public boolean hasAttachments() {
+               for (Change change : changes) {
+                       if (change.hasAttachments()) {
+                               return true;
+                       }
+               }
+               return false;
+       }
+
+       public List<Attachment> getAttachments() {
+               List<Attachment> list = new ArrayList<Attachment>();
+               for (Change change : changes) {
+                       if (change.hasAttachments()) {
+                               list.addAll(change.attachments);
+                       }
+               }
+               return list;
+       }
+
+       public List<Patchset> getPatchsets() {
+               List<Patchset> list = new ArrayList<Patchset>();
+               for (Change change : changes) {
+                       if (change.patchset != null) {
+                               list.add(change.patchset);
+                       }
+               }
+               return list;
+       }
+
+       public List<Patchset> getPatchsetRevisions(int number) {
+               List<Patchset> list = new ArrayList<Patchset>();
+               for (Change change : changes) {
+                       if (change.patchset != null) {
+                               if (number == change.patchset.number) {
+                                       list.add(change.patchset);
+                               }
+                       }
+               }
+               return list;
+       }
+
+       public Patchset getPatchset(String sha) {
+               for (Change change : changes) {
+                       if (change.patchset != null) {
+                               if (sha.equals(change.patchset.tip)) {
+                                       return change.patchset;
+                               }
+                       }
+               }
+               return null;
+       }
+
+       public Patchset getPatchset(int number, int rev) {
+               for (Change change : changes) {
+                       if (change.patchset != null) {
+                               if (number == change.patchset.number && rev == change.patchset.rev) {
+                                       return change.patchset;
+                               }
+                       }
+               }
+               return null;
+       }
+
+       public Patchset getCurrentPatchset() {
+               Patchset patchset = null;
+               for (Change change : changes) {
+                       if (change.patchset != null) {
+                               if (patchset == null) {
+                                       patchset = change.patchset;
+                               } else if (patchset.compareTo(change.patchset) == 1) {
+                                       patchset = change.patchset;
+                               }
+                       }
+               }
+               return patchset;
+       }
+
+       public boolean isCurrent(Patchset patchset) {
+               if (patchset == null) {
+                       return false;
+               }
+               Patchset curr = getCurrentPatchset();
+               if (curr == null) {
+                       return false;
+               }
+               return curr.equals(patchset);
+       }
+
+       public List<Change> getReviews(Patchset patchset) {
+               if (patchset == null) {
+                       return Collections.emptyList();
+               }
+               // collect the patchset reviews by author
+               // the last review by the author is the
+               // official review
+               Map<String, Change> reviews = new LinkedHashMap<String, TicketModel.Change>();
+               for (Change change : changes) {
+                       if (change.hasReview()) {
+                               if (change.review.isReviewOf(patchset)) {
+                                       reviews.put(change.author, change);
+                               }
+                       }
+               }
+               return new ArrayList<Change>(reviews.values());
+       }
+
+
+       public boolean isApproved(Patchset patchset) {
+               if (patchset == null) {
+                       return false;
+               }
+               boolean approved = false;
+               boolean vetoed = false;
+               for (Change change : getReviews(patchset)) {
+                       if (change.hasReview()) {
+                               if (change.review.isReviewOf(patchset)) {
+                                       if (Score.approved == change.review.score) {
+                                               approved = true;
+                                       } else if (Score.vetoed == change.review.score) {
+                                               vetoed = true;
+                                       }
+                               }
+                       }
+               }
+               return approved && !vetoed;
+       }
+
+       public boolean isVetoed(Patchset patchset) {
+               if (patchset == null) {
+                       return false;
+               }
+               for (Change change : getReviews(patchset)) {
+                       if (change.hasReview()) {
+                               if (change.review.isReviewOf(patchset)) {
+                                       if (Score.vetoed == change.review.score) {
+                                               return true;
+                                       }
+                               }
+                       }
+               }
+               return false;
+       }
+
+       public Review getReviewBy(String username) {
+               for (Change change : getReviews(getCurrentPatchset())) {
+                       if (change.author.equals(username)) {
+                               return change.review;
+                       }
+               }
+               return null;
+       }
+
+       public boolean isPatchsetAuthor(String username) {
+               for (Change change : changes) {
+                       if (change.hasPatchset()) {
+                               if (change.author.equals(username)) {
+                                       return true;
+                               }
+                       }
+               }
+               return false;
+       }
+
+       public void applyChange(Change change) {
+               if (changes.size() == 0) {
+                       // first change created the ticket
+                       created = change.date;
+                       createdBy = change.author;
+                       status = Status.New;
+               } else if (created == null || change.date.after(created)) {
+                       // track last ticket update
+                       updated = change.date;
+                       updatedBy = change.author;
+               }
+
+               if (change.isMerge()) {
+                       // identify merge patchsets
+                       if (isEmpty(responsible)) {
+                               responsible = change.author;
+                       }
+                       status = Status.Merged;
+               }
+
+               if (change.hasFieldChanges()) {
+                       for (Map.Entry<Field, String> entry : change.fields.entrySet()) {
+                               Field field = entry.getKey();
+                               Object value = entry.getValue();
+                               switch (field) {
+                               case type:
+                                       type = TicketModel.Type.fromObject(value, type);
+                                       break;
+                               case status:
+                                       status = TicketModel.Status.fromObject(value, status);
+                                       break;
+                               case title:
+                                       title = toString(value);
+                                       break;
+                               case body:
+                                       body = toString(value);
+                                       break;
+                               case topic:
+                                       topic = toString(value);
+                                       break;
+                               case responsible:
+                                       responsible = toString(value);
+                                       break;
+                               case milestone:
+                                       milestone = toString(value);
+                                       break;
+                               case mergeTo:
+                                       mergeTo = toString(value);
+                                       break;
+                               case mergeSha:
+                                       mergeSha = toString(value);
+                                       break;
+                               default:
+                                       // unknown
+                                       break;
+                               }
+                       }
+               }
+
+               // add the change to the ticket
+               changes.add(change);
+       }
+
+       protected String toString(Object value) {
+               if (value == null) {
+                       return null;
+               }
+               return value.toString();
+       }
+
+       public String toIndexableString() {
+               StringBuilder sb = new StringBuilder();
+               if (!isEmpty(title)) {
+                       sb.append(title).append('\n');
+               }
+               if (!isEmpty(body)) {
+                       sb.append(body).append('\n');
+               }
+               for (Change change : changes) {
+                       if (change.hasComment()) {
+                               sb.append(change.comment.text);
+                               sb.append('\n');
+                       }
+               }
+               return sb.toString();
+       }
+
+       @Override
+       public String toString() {
+               StringBuilder sb = new StringBuilder();
+               sb.append("#");
+               sb.append(number);
+               sb.append(": " + title + "\n");
+               for (Change change : changes) {
+                       sb.append(change);
+                       sb.append('\n');
+               }
+               return sb.toString();
+       }
+
+       @Override
+       public int compareTo(TicketModel o) {
+               return o.created.compareTo(created);
+       }
+
+       @Override
+       public boolean equals(Object o) {
+               if (o instanceof TicketModel) {
+                       return number == ((TicketModel) o).number;
+               }
+               return super.equals(o);
+       }
+
+       @Override
+       public int hashCode() {
+               return (repository + number).hashCode();
+       }
+
+       /**
+        * Encapsulates a ticket change
+        */
+       public static class Change implements Serializable, Comparable<Change> {
+
+               private static final long serialVersionUID = 1L;
+
+               public final Date date;
+
+               public final String author;
+
+               public Comment comment;
+
+               public Map<Field, String> fields;
+
+               public Set<Attachment> attachments;
+
+               public Patchset patchset;
+
+               public Review review;
+
+               private transient String id;
+
+               public Change(String author) {
+                       this(author, new Date());
+               }
+
+               public Change(String author, Date date) {
+                       this.date = date;
+                       this.author = author;
+               }
+
+               public boolean isStatusChange() {
+                       return hasField(Field.status);
+               }
+
+               public Status getStatus() {
+                       Status state = Status.fromObject(getField(Field.status), null);
+                       return state;
+               }
+
+               public boolean isMerge() {
+                       return hasField(Field.status) && hasField(Field.mergeSha);
+               }
+
+               public boolean hasPatchset() {
+                       return patchset != null;
+               }
+
+               public boolean hasReview() {
+                       return review != null;
+               }
+
+               public boolean hasComment() {
+                       return comment != null && !comment.isDeleted();
+               }
+
+               public Comment comment(String text) {
+                       comment = new Comment(text);
+                       comment.id = TicketModel.getSHA1(date.toString() + author + text);
+
+                       try {
+                               Pattern mentions = Pattern.compile("\\s@([A-Za-z0-9-_]+)");
+                               Matcher m = mentions.matcher(text);
+                               while (m.find()) {
+                                       String username = m.group(1);
+                                       plusList(Field.mentions, username);
+                               }
+                       } catch (Exception e) {
+                               // ignore
+                       }
+                       return comment;
+               }
+
+               public Review review(Patchset patchset, Score score, boolean addReviewer) {
+                       if (addReviewer) {
+                               plusList(Field.reviewers, author);
+                       }
+                       review = new Review(patchset.number, patchset.rev);
+                       review.score = score;
+                       return review;
+               }
+
+               public boolean hasAttachments() {
+                       return !TicketModel.isEmpty(attachments);
+               }
+
+               public void addAttachment(Attachment attachment) {
+                       if (attachments == null) {
+                               attachments = new LinkedHashSet<Attachment>();
+                       }
+                       attachments.add(attachment);
+               }
+
+               public Attachment getAttachment(String name) {
+                       if (attachments != null) {
+                               for (Attachment attachment : attachments) {
+                                       if (attachment.name.equalsIgnoreCase(name)) {
+                                               return attachment;
+                                       }
+                               }
+                       }
+                       return null;
+               }
+
+               public boolean isParticipantChange() {
+                       if (hasComment()
+                                       || hasReview()
+                                       || hasPatchset()
+                                       || hasAttachments()) {
+                               return true;
+                       }
+
+                       if (TicketModel.isEmpty(fields)) {
+                               return false;
+                       }
+
+                       // identify real ticket field changes
+                       Map<Field, String> map = new HashMap<Field, String>(fields);
+                       map.remove(Field.watchers);
+                       map.remove(Field.voters);
+                       return !map.isEmpty();
+               }
+
+               public boolean hasField(Field field) {
+                       return !TicketModel.isEmpty(getString(field));
+               }
+
+               public boolean hasFieldChanges() {
+                       return !TicketModel.isEmpty(fields);
+               }
+
+               public String getField(Field field) {
+                       if (fields != null) {
+                               return fields.get(field);
+                       }
+                       return null;
+               }
+
+               public void setField(Field field, Object value) {
+                       if (fields == null) {
+                               fields = new LinkedHashMap<Field, String>();
+                       }
+                       if (value == null) {
+                               fields.put(field, null);
+                       } else if (Enum.class.isAssignableFrom(value.getClass())) {
+                               fields.put(field, ((Enum<?>) value).name());
+                       } else {
+                               fields.put(field, value.toString());
+                       }
+               }
+
+               public void remove(Field field) {
+                       if (fields != null) {
+                               fields.remove(field);
+                       }
+               }
+
+               public String getString(Field field) {
+                       String value = getField(field);
+                       if (value == null) {
+                               return null;
+                       }
+                       return value;
+               }
+
+               public void watch(String... username) {
+                       plusList(Field.watchers, username);
+               }
+
+               public void unwatch(String... username) {
+                       minusList(Field.watchers, username);
+               }
+
+               public void vote(String... username) {
+                       plusList(Field.voters, username);
+               }
+
+               public void unvote(String... username) {
+                       minusList(Field.voters, username);
+               }
+
+               public void label(String... label) {
+                       plusList(Field.labels, label);
+               }
+
+               public void unlabel(String... label) {
+                       minusList(Field.labels, label);
+               }
+
+               protected void plusList(Field field, String... items) {
+                       modList(field, "+", items);
+               }
+
+               protected void minusList(Field field, String... items) {
+                       modList(field, "-", items);
+               }
+
+               private void modList(Field field, String prefix, String... items) {
+                       List<String> list = new ArrayList<String>();
+                       for (String item : items) {
+                               list.add(prefix + item);
+                       }
+                       setField(field, join(list, ","));
+               }
+
+               public String getId() {
+                       if (id == null) {
+                               id = getSHA1(Long.toHexString(date.getTime()) + author);
+                       }
+                       return id;
+               }
+
+               @Override
+               public int compareTo(Change c) {
+                       return date.compareTo(c.date);
+               }
+
+               @Override
+               public int hashCode() {
+                       return getId().hashCode();
+               }
+
+               @Override
+               public boolean equals(Object o) {
+                       if (o instanceof Change) {
+                               return getId().equals(((Change) o).getId());
+                       }
+                       return false;
+               }
+
+               @Override
+               public String toString() {
+                       StringBuilder sb = new StringBuilder();
+                       sb.append(RelativeDateFormatter.format(date));
+                       if (hasComment()) {
+                               sb.append(" commented on by ");
+                       } else if (hasPatchset()) {
+                               sb.append(MessageFormat.format(" {0} uploaded by ", patchset));
+                       } else {
+                               sb.append(" changed by ");
+                       }
+                       sb.append(author).append(" - ");
+                       if (hasComment()) {
+                               if (comment.isDeleted()) {
+                                       sb.append("(deleted) ");
+                               }
+                               sb.append(comment.text).append(" ");
+                       }
+
+                       if (hasFieldChanges()) {
+                               for (Map.Entry<Field, String> entry : fields.entrySet()) {
+                                       sb.append("\n  ");
+                                       sb.append(entry.getKey().name());
+                                       sb.append(':');
+                                       sb.append(entry.getValue());
+                               }
+                       }
+                       return sb.toString();
+               }
+       }
+
+       /**
+        * Returns true if the string is null or empty.
+        *
+        * @param value
+        * @return true if string is null or empty
+        */
+       static boolean isEmpty(String value) {
+               return value == null || value.trim().length() == 0;
+       }
+
+       /**
+        * Returns true if the collection is null or empty
+        *
+        * @param collection
+        * @return
+        */
+       static boolean isEmpty(Collection<?> collection) {
+               return collection == null || collection.size() == 0;
+       }
+
+       /**
+        * Returns true if the map is null or empty
+        *
+        * @param map
+        * @return
+        */
+       static boolean isEmpty(Map<?, ?> map) {
+               return map == null || map.size() == 0;
+       }
+
+       /**
+        * Calculates the SHA1 of the string.
+        *
+        * @param text
+        * @return sha1 of the string
+        */
+       static String getSHA1(String text) {
+               try {
+                       byte[] bytes = text.getBytes("iso-8859-1");
+                       return getSHA1(bytes);
+               } catch (UnsupportedEncodingException u) {
+                       throw new RuntimeException(u);
+               }
+       }
+
+       /**
+        * Calculates the SHA1 of the byte array.
+        *
+        * @param bytes
+        * @return sha1 of the byte array
+        */
+       static String getSHA1(byte[] bytes) {
+               try {
+                       MessageDigest md = MessageDigest.getInstance("SHA-1");
+                       md.update(bytes, 0, bytes.length);
+                       byte[] digest = md.digest();
+                       return toHex(digest);
+               } catch (NoSuchAlgorithmException t) {
+                       throw new RuntimeException(t);
+               }
+       }
+
+       /**
+        * Returns the hex representation of the byte array.
+        *
+        * @param bytes
+        * @return byte array as hex string
+        */
+       static String toHex(byte[] bytes) {
+               StringBuilder sb = new StringBuilder(bytes.length * 2);
+               for (int i = 0; i < bytes.length; i++) {
+                       if ((bytes[i] & 0xff) < 0x10) {
+                               sb.append('0');
+                       }
+                       sb.append(Long.toString(bytes[i] & 0xff, 16));
+               }
+               return sb.toString();
+       }
+
+       /**
+        * Join the list of strings into a single string with a space separator.
+        *
+        * @param values
+        * @return joined list
+        */
+       static String join(Collection<String> values) {
+               return join(values, " ");
+       }
+
+       /**
+        * Join the list of strings into a single string with the specified
+        * separator.
+        *
+        * @param values
+        * @param separator
+        * @return joined list
+        */
+       static String join(String[]  values, String separator) {
+               return join(Arrays.asList(values), separator);
+       }
+
+       /**
+        * Join the list of strings into a single string with the specified
+        * separator.
+        *
+        * @param values
+        * @param separator
+        * @return joined list
+        */
+       static String join(Collection<String> values, String separator) {
+               StringBuilder sb = new StringBuilder();
+               for (String value : values) {
+                       sb.append(value).append(separator);
+               }
+               if (sb.length() > 0) {
+                       // truncate trailing separator
+                       sb.setLength(sb.length() - separator.length());
+               }
+               return sb.toString().trim();
+       }
+
+
+       /**
+        * Produce a deep copy of the given object. Serializes the entire object to
+        * a byte array in memory. Recommended for relatively small objects.
+        */
+       @SuppressWarnings("unchecked")
+       static <T> T copy(T original) {
+               T o = null;
+               try {
+                       ByteArrayOutputStream byteOut = new ByteArrayOutputStream();
+                       ObjectOutputStream oos = new ObjectOutputStream(byteOut);
+                       oos.writeObject(original);
+                       ByteArrayInputStream byteIn = new ByteArrayInputStream(byteOut.toByteArray());
+                       ObjectInputStream ois = new ObjectInputStream(byteIn);
+                       try {
+                               o = (T) ois.readObject();
+                       } catch (ClassNotFoundException cex) {
+                               // actually can not happen in this instance
+                       }
+               } catch (IOException iox) {
+                       // doesn't seem likely to happen as these streams are in memory
+                       throw new RuntimeException(iox);
+               }
+               return o;
+       }
+
+       public static class Patchset implements Serializable, Comparable<Patchset> {
+
+               private static final long serialVersionUID = 1L;
+
+               public int number;
+               public int rev;
+               public String tip;
+               public String parent;
+               public String base;
+               public int insertions;
+               public int deletions;
+               public int commits;
+               public int added;
+               public PatchsetType type;
+
+               public boolean isFF() {
+                       return PatchsetType.FastForward == type;
+               }
+
+               @Override
+               public int hashCode() {
+                       return toString().hashCode();
+               }
+
+               @Override
+               public boolean equals(Object o) {
+                       if (o instanceof Patchset) {
+                               return hashCode() == o.hashCode();
+                       }
+                       return false;
+               }
+
+               @Override
+               public int compareTo(Patchset p) {
+                       if (number > p.number) {
+                               return -1;
+                       } else if (p.number > number) {
+                               return 1;
+                       } else {
+                               // same patchset, different revision
+                               if (rev > p.rev) {
+                                       return -1;
+                               } else if (p.rev > rev) {
+                                       return 1;
+                               } else {
+                                       // same patchset & revision
+                                       return 0;
+                               }
+                       }
+               }
+
+               @Override
+               public String toString() {
+                       return "patchset " + number + " revision " + rev;
+               }
+       }
+
+       public static class Comment implements Serializable {
+
+               private static final long serialVersionUID = 1L;
+
+               public String text;
+
+               public String id;
+
+               public Boolean deleted;
+
+               public CommentSource src;
+
+               public String replyTo;
+
+               Comment(String text) {
+                       this.text = text;
+               }
+
+               public boolean isDeleted() {
+                       return deleted != null && deleted;
+               }
+
+               @Override
+               public String toString() {
+                       return text;
+               }
+       }
+
+       public static class Attachment implements Serializable {
+
+               private static final long serialVersionUID = 1L;
+
+               public final String name;
+               public long size;
+               public byte[] content;
+               public Boolean deleted;
+
+               public Attachment(String name) {
+                       this.name = name;
+               }
+
+               public boolean isDeleted() {
+                       return deleted != null && deleted;
+               }
+
+               @Override
+               public int hashCode() {
+                       return name.hashCode();
+               }
+
+               @Override
+               public boolean equals(Object o) {
+                       if (o instanceof Attachment) {
+                               return name.equalsIgnoreCase(((Attachment) o).name);
+                       }
+                       return false;
+               }
+
+               @Override
+               public String toString() {
+                       return name;
+               }
+       }
+
+       public static class Review implements Serializable {
+
+               private static final long serialVersionUID = 1L;
+
+               public final int patchset;
+
+               public final int rev;
+
+               public Score score;
+
+               public Review(int patchset, int revision) {
+                       this.patchset = patchset;
+                       this.rev = revision;
+               }
+
+               public boolean isReviewOf(Patchset p) {
+                       return patchset == p.number && rev == p.rev;
+               }
+
+               @Override
+               public String toString() {
+                       return "review of patchset " + patchset + " rev " + rev + ":" + score;
+               }
+       }
+
+       public static enum Score {
+               approved(2), looks_good(1), not_reviewed(0), needs_improvement(-1), vetoed(-2);
+
+               final int value;
+
+               Score(int value) {
+                       this.value = value;
+               }
+
+               public int getValue() {
+                       return value;
+               }
+
+               @Override
+               public String toString() {
+                       return name().toLowerCase().replace('_', ' ');
+               }
+       }
+
+       public static enum Field {
+               title, body, responsible, type, status, milestone, mergeSha, mergeTo,
+               topic, labels, watchers, reviewers, voters, mentions;
+       }
+
+       public static enum Type {
+               Enhancement, Task, Bug, Proposal, Question;
+
+               public static Type defaultType = Task;
+
+               public static Type [] choices() {
+                       return new Type [] { Enhancement, Task, Bug, Question };
+               }
+
+               @Override
+               public String toString() {
+                       return name().toLowerCase().replace('_', ' ');
+               }
+
+               public static Type fromObject(Object o, Type defaultType) {
+                       if (o instanceof Type) {
+                               // cast and return
+                               return (Type) o;
+                       } else if (o instanceof String) {
+                               // find by name
+                               for (Type type : values()) {
+                                       String str = o.toString();
+                                       if (type.name().equalsIgnoreCase(str)
+                                                       || type.toString().equalsIgnoreCase(str)) {
+                                               return type;
+                                       }
+                               }
+                       } else if (o instanceof Number) {
+                               // by ordinal
+                               int id = ((Number) o).intValue();
+                               if (id >= 0 && id < values().length) {
+                                       return values()[id];
+                               }
+                       }
+
+                       return defaultType;
+               }
+       }
+
+       public static enum Status {
+               New, Open, Resolved, Fixed, Merged, Wontfix, Declined, Duplicate, Invalid, On_Hold;
+
+               public static Status [] requestWorkflow = { Open, Resolved, Declined, Duplicate, Invalid, On_Hold };
+
+               public static Status [] bugWorkflow = { Open, Fixed, Wontfix, Duplicate, Invalid, On_Hold };
+
+               public static Status [] proposalWorkflow = { Open, Declined, On_Hold};
+
+               @Override
+               public String toString() {
+                       return name().toLowerCase().replace('_', ' ');
+               }
+
+               public static Status fromObject(Object o, Status defaultStatus) {
+                       if (o instanceof Status) {
+                               // cast and return
+                               return (Status) o;
+                       } else if (o instanceof String) {
+                               // find by name
+                               String name = o.toString();
+                               for (Status state : values()) {
+                                       if (state.name().equalsIgnoreCase(name)
+                                                       || state.toString().equalsIgnoreCase(name)) {
+                                               return state;
+                                       }
+                               }
+                       } else if (o instanceof Number) {
+                               // by ordinal
+                               int id = ((Number) o).intValue();
+                               if (id >= 0 && id < values().length) {
+                                       return values()[id];
+                               }
+                       }
+
+                       return defaultStatus;
+               }
+
+               public boolean isClosed() {
+                       return ordinal() > Open.ordinal();
+               }
+       }
+
+       public static enum CommentSource {
+               Comment, Email
+       }
+
+       public static enum PatchsetType {
+               Proposal, FastForward, Rebase, Squash, Rebase_Squash, Amend;
+
+               public boolean isRewrite() {
+                       return (this != FastForward) && (this != Proposal);
+               }
+
+               @Override
+               public String toString() {
+                       return name().toLowerCase().replace('_', '+');
+               }
+
+               public static PatchsetType fromObject(Object o) {
+                       if (o instanceof PatchsetType) {
+                               // cast and return
+                               return (PatchsetType) o;
+                       } else if (o instanceof String) {
+                               // find by name
+                               String name = o.toString();
+                               for (PatchsetType type : values()) {
+                                       if (type.name().equalsIgnoreCase(name)
+                                                       || type.toString().equalsIgnoreCase(name)) {
+                                               return type;
+                                       }
+                               }
+                       } else if (o instanceof Number) {
+                               // by ordinal
+                               int id = ((Number) o).intValue();
+                               if (id >= 0 && id < values().length) {
+                                       return values()[id];
+                               }
+                       }
+
+                       return null;
+               }
+       }
+}
index 6419cce91257c0fb3fb779e37334c08a3dcf8452..63208f359a4b140fc77a94fc8f4dde2b416b0306 100644 (file)
@@ -446,6 +446,18 @@ public class UserModel implements Principal, Serializable, Comparable<UserModel>
                return canAdmin() || model.isUsersPersonalRepository(username) || model.isOwner(username);\r
        }\r
 \r
+       public boolean canReviewPatchset(RepositoryModel model) {\r
+               return isAuthenticated && canClone(model);\r
+       }\r
+\r
+       public boolean canApprovePatchset(RepositoryModel model) {\r
+               return isAuthenticated && canPush(model);\r
+       }\r
+\r
+       public boolean canVetoPatchset(RepositoryModel model) {\r
+               return isAuthenticated && canPush(model);\r
+       }\r
+\r
        /**\r
         * This returns true if the user has fork privileges or the user has fork\r
         * privileges because of a team membership.\r
diff --git a/src/main/java/com/gitblit/servlet/PtServlet.java b/src/main/java/com/gitblit/servlet/PtServlet.java
new file mode 100644 (file)
index 0000000..e9cbaa5
--- /dev/null
@@ -0,0 +1,201 @@
+/*\r
+ * Copyright 2014 gitblit.com.\r
+ *\r
+ * Licensed under the Apache License, Version 2.0 (the "License");\r
+ * you may not use this file except in compliance with the License.\r
+ * You may obtain a copy of the License at\r
+ *\r
+ *     http://www.apache.org/licenses/LICENSE-2.0\r
+ *\r
+ * Unless required by applicable law or agreed to in writing, software\r
+ * distributed under the License is distributed on an "AS IS" BASIS,\r
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r
+ * See the License for the specific language governing permissions and\r
+ * limitations under the License.\r
+ */\r
+package com.gitblit.servlet;\r
+\r
+import java.io.File;\r
+import java.io.FileInputStream;\r
+import java.io.IOException;\r
+import java.io.InputStream;\r
+import java.io.OutputStream;\r
+\r
+import javax.servlet.ServletException;\r
+import javax.servlet.http.HttpServletRequest;\r
+import javax.servlet.http.HttpServletResponse;\r
+\r
+import org.apache.commons.compress.archivers.tar.TarArchiveEntry;\r
+import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;\r
+import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;\r
+import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;\r
+import org.apache.commons.compress.compressors.CompressorOutputStream;\r
+import org.apache.commons.compress.compressors.CompressorStreamFactory;\r
+import org.apache.wicket.util.io.ByteArrayOutputStream;\r
+import org.eclipse.jgit.lib.FileMode;\r
+\r
+import com.gitblit.dagger.DaggerServlet;\r
+import com.gitblit.manager.IRuntimeManager;\r
+\r
+import dagger.ObjectGraph;\r
+\r
+/**\r
+ * Handles requests for the Barnum pt (patchset tool).\r
+ *\r
+ * The user-agent determines the content and compression format.\r
+ *\r
+ * @author James Moger\r
+ *\r
+ */\r
+public class PtServlet extends DaggerServlet {\r
+\r
+       private static final long serialVersionUID = 1L;\r
+\r
+       private static final long lastModified = System.currentTimeMillis();\r
+\r
+       private IRuntimeManager runtimeManager;\r
+\r
+       @Override\r
+       protected void inject(ObjectGraph dagger) {\r
+               this.runtimeManager = dagger.get(IRuntimeManager.class);\r
+       }\r
+\r
+       @Override\r
+       protected long getLastModified(HttpServletRequest req) {\r
+               File file = runtimeManager.getFileOrFolder("tickets.pt", "${baseFolder}/pt.py");\r
+               if (file.exists()) {\r
+                       return Math.max(lastModified, file.lastModified());\r
+               } else {\r
+                       return lastModified;\r
+               }\r
+       }\r
+\r
+       @Override\r
+       protected void doGet(HttpServletRequest request, HttpServletResponse response)\r
+                       throws ServletException, IOException {\r
+               try {\r
+                       response.setContentType("application/octet-stream");\r
+                       response.setDateHeader("Last-Modified", lastModified);\r
+                       response.setHeader("Cache-Control", "none");\r
+                       response.setHeader("Pragma", "no-cache");\r
+                       response.setDateHeader("Expires", 0);\r
+\r
+                       boolean windows = false;\r
+                       try {\r
+                               String useragent = request.getHeader("user-agent").toString();\r
+                               windows = useragent.toLowerCase().contains("windows");\r
+                       } catch (Exception e) {\r
+                       }\r
+\r
+                       byte[] pyBytes;\r
+                       File file = runtimeManager.getFileOrFolder("tickets.pt", "${baseFolder}/pt.py");\r
+                       if (file.exists()) {\r
+                               // custom script\r
+                               pyBytes = readAll(new FileInputStream(file));\r
+                       } else {\r
+                               // default script\r
+                               pyBytes = readAll(getClass().getResourceAsStream("/pt.py"));\r
+                       }\r
+\r
+                       if (windows) {\r
+                               // windows: download zip file with pt.py and pt.cmd\r
+                               response.setHeader("Content-Disposition", "attachment; filename=\"pt.zip\"");\r
+\r
+                               OutputStream os = response.getOutputStream();\r
+                               ZipArchiveOutputStream zos = new ZipArchiveOutputStream(os);\r
+\r
+                               // add the Python script\r
+                               ZipArchiveEntry pyEntry = new ZipArchiveEntry("pt.py");\r
+                               pyEntry.setSize(pyBytes.length);\r
+                               pyEntry.setUnixMode(FileMode.EXECUTABLE_FILE.getBits());\r
+                               pyEntry.setTime(lastModified);\r
+                               zos.putArchiveEntry(pyEntry);\r
+                               zos.write(pyBytes);\r
+                               zos.closeArchiveEntry();\r
+\r
+                               // add a Python launch cmd file\r
+                               byte [] cmdBytes = readAll(getClass().getResourceAsStream("/pt.cmd"));\r
+                               ZipArchiveEntry cmdEntry = new ZipArchiveEntry("pt.cmd");\r
+                               cmdEntry.setSize(cmdBytes.length);\r
+                               cmdEntry.setUnixMode(FileMode.REGULAR_FILE.getBits());\r
+                               cmdEntry.setTime(lastModified);\r
+                               zos.putArchiveEntry(cmdEntry);\r
+                               zos.write(cmdBytes);\r
+                               zos.closeArchiveEntry();\r
+\r
+                               // add a brief readme\r
+                               byte [] txtBytes = readAll(getClass().getResourceAsStream("/pt.txt"));\r
+                               ZipArchiveEntry txtEntry = new ZipArchiveEntry("readme.txt");\r
+                               txtEntry.setSize(txtBytes.length);\r
+                               txtEntry.setUnixMode(FileMode.REGULAR_FILE.getBits());\r
+                               txtEntry.setTime(lastModified);\r
+                               zos.putArchiveEntry(txtEntry);\r
+                               zos.write(txtBytes);\r
+                               zos.closeArchiveEntry();\r
+\r
+                               // cleanup\r
+                               zos.finish();\r
+                               zos.close();\r
+                               os.flush();\r
+                       } else {\r
+                               // unix: download a tar.gz file with pt.py set with execute permissions\r
+                               response.setHeader("Content-Disposition", "attachment; filename=\"pt.tar.gz\"");\r
+\r
+                               OutputStream os = response.getOutputStream();\r
+                               CompressorOutputStream cos = new CompressorStreamFactory().createCompressorOutputStream(CompressorStreamFactory.GZIP, os);\r
+                               TarArchiveOutputStream tos = new TarArchiveOutputStream(cos);\r
+                               tos.setAddPaxHeadersForNonAsciiNames(true);\r
+                               tos.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX);\r
+\r
+                               // add the Python script\r
+                               TarArchiveEntry pyEntry = new TarArchiveEntry("pt");\r
+                               pyEntry.setMode(FileMode.EXECUTABLE_FILE.getBits());\r
+                               pyEntry.setModTime(lastModified);\r
+                               pyEntry.setSize(pyBytes.length);\r
+                               tos.putArchiveEntry(pyEntry);\r
+                               tos.write(pyBytes);\r
+                               tos.closeArchiveEntry();\r
+\r
+                               // add a brief readme\r
+                               byte [] txtBytes = readAll(getClass().getResourceAsStream("/pt.txt"));\r
+                               TarArchiveEntry txtEntry = new TarArchiveEntry("README");\r
+                               txtEntry.setMode(FileMode.REGULAR_FILE.getBits());\r
+                               txtEntry.setModTime(lastModified);\r
+                               txtEntry.setSize(txtBytes.length);\r
+                               tos.putArchiveEntry(txtEntry);\r
+                               tos.write(txtBytes);\r
+                               tos.closeArchiveEntry();\r
+\r
+                               // cleanup\r
+                               tos.finish();\r
+                               tos.close();\r
+                               cos.close();\r
+                               os.flush();\r
+                       }\r
+               } catch (Exception e) {\r
+                       e.printStackTrace();\r
+               }\r
+       }\r
+\r
+       byte [] readAll(InputStream is) {\r
+               ByteArrayOutputStream os = new ByteArrayOutputStream();\r
+               try {\r
+                       byte [] buffer = new byte[4096];\r
+                       int len = 0;\r
+                       while ((len = is.read(buffer)) > -1) {\r
+                               os.write(buffer, 0, len);\r
+                       }\r
+                       return os.toByteArray();\r
+               } catch (IOException e) {\r
+                       e.printStackTrace();\r
+               } finally {\r
+                       try {\r
+                               os.close();\r
+                               is.close();\r
+                       } catch (Exception e) {\r
+                               // ignore\r
+                       }\r
+               }\r
+               return new byte[0];\r
+       }\r
+}\r
diff --git a/src/main/java/com/gitblit/tickets/BranchTicketService.java b/src/main/java/com/gitblit/tickets/BranchTicketService.java
new file mode 100644 (file)
index 0000000..14ed809
--- /dev/null
@@ -0,0 +1,799 @@
+/*
+ * Copyright 2014 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.tickets;
+
+import java.io.IOException;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+
+import javax.inject.Inject;
+
+import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException;
+import org.eclipse.jgit.api.errors.JGitInternalException;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.dircache.DirCacheBuilder;
+import org.eclipse.jgit.dircache.DirCacheEntry;
+import org.eclipse.jgit.internal.JGitText;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.CanonicalTreeParser;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+import com.gitblit.Constants;
+import com.gitblit.manager.INotificationManager;
+import com.gitblit.manager.IRepositoryManager;
+import com.gitblit.manager.IRuntimeManager;
+import com.gitblit.manager.IUserManager;
+import com.gitblit.models.PathModel;
+import com.gitblit.models.RefModel;
+import com.gitblit.models.RepositoryModel;
+import com.gitblit.models.TicketModel;
+import com.gitblit.models.TicketModel.Attachment;
+import com.gitblit.models.TicketModel.Change;
+import com.gitblit.utils.ArrayUtils;
+import com.gitblit.utils.JGitUtils;
+import com.gitblit.utils.StringUtils;
+
+/**
+ * Implementation of a ticket service based on an orphan branch.  All tickets
+ * are serialized as a list of JSON changes and persisted in a hashed directory
+ * structure, similar to the standard git loose object structure.
+ *
+ * @author James Moger
+ *
+ */
+public class BranchTicketService extends ITicketService {
+
+       public static final String BRANCH = "refs/gitblit/tickets";
+
+       private static final String JOURNAL = "journal.json";
+
+       private static final String ID_PATH = "id/";
+
+       private final Map<String, AtomicLong> lastAssignedId;
+
+       @Inject
+       public BranchTicketService(
+                       IRuntimeManager runtimeManager,
+                       INotificationManager notificationManager,
+                       IUserManager userManager,
+                       IRepositoryManager repositoryManager) {
+
+               super(runtimeManager,
+                               notificationManager,
+                               userManager,
+                               repositoryManager);
+
+               lastAssignedId = new ConcurrentHashMap<String, AtomicLong>();
+       }
+
+       @Override
+       public BranchTicketService start() {
+               return this;
+       }
+
+       @Override
+       protected void resetCachesImpl() {
+               lastAssignedId.clear();
+       }
+
+       @Override
+       protected void resetCachesImpl(RepositoryModel repository) {
+               if (lastAssignedId.containsKey(repository.name)) {
+                       lastAssignedId.get(repository.name).set(0);
+               }
+       }
+
+       @Override
+       protected void close() {
+       }
+
+       /**
+        * Returns a RefModel for the refs/gitblit/tickets branch in the repository.
+        * If the branch can not be found, null is returned.
+        *
+        * @return a refmodel for the gitblit tickets branch or null
+        */
+       private RefModel getTicketsBranch(Repository db) {
+               List<RefModel> refs = JGitUtils.getRefs(db, Constants.R_GITBLIT);
+               for (RefModel ref : refs) {
+                       if (ref.reference.getName().equals(BRANCH)) {
+                               return ref;
+                       }
+               }
+               return null;
+       }
+
+       /**
+        * Creates the refs/gitblit/tickets branch.
+        * @param db
+        */
+       private void createTicketsBranch(Repository db) {
+               JGitUtils.createOrphanBranch(db, BRANCH, null);
+       }
+
+       /**
+        * Returns the ticket path. This follows the same scheme as Git's object
+        * store path where the first two characters of the hash id are the root
+        * folder with the remaining characters as a subfolder within that folder.
+        *
+        * @param ticketId
+        * @return the root path of the ticket content on the refs/gitblit/tickets branch
+        */
+       private String toTicketPath(long ticketId) {
+               StringBuilder sb = new StringBuilder();
+               sb.append(ID_PATH);
+               long m = ticketId % 100L;
+               if (m < 10) {
+                       sb.append('0');
+               }
+               sb.append(m);
+               sb.append('/');
+               sb.append(ticketId);
+               return sb.toString();
+       }
+
+       /**
+        * Returns the path to the attachment for the specified ticket.
+        *
+        * @param ticketId
+        * @param filename
+        * @return the path to the specified attachment
+        */
+       private String toAttachmentPath(long ticketId, String filename) {
+               return toTicketPath(ticketId) + "/attachments/" + filename;
+       }
+
+       /**
+        * Reads a file from the tickets branch.
+        *
+        * @param db
+        * @param file
+        * @return the file content or null
+        */
+       private String readTicketsFile(Repository db, String file) {
+               RevWalk rw = null;
+               try {
+                       ObjectId treeId = db.resolve(BRANCH + "^{tree}");
+                       if (treeId == null) {
+                               return null;
+                       }
+                       rw = new RevWalk(db);
+                       RevTree tree = rw.lookupTree(treeId);
+                       if (tree != null) {
+                               return JGitUtils.getStringContent(db, tree, file, Constants.ENCODING);
+                       }
+               } catch (IOException e) {
+                       log.error("failed to read " + file, e);
+               } finally {
+                       if (rw != null) {
+                               rw.release();
+                       }
+               }
+               return null;
+       }
+
+       /**
+        * Writes a file to the tickets branch.
+        *
+        * @param db
+        * @param file
+        * @param content
+        * @param createdBy
+        * @param msg
+        */
+       private void writeTicketsFile(Repository db, String file, String content, String createdBy, String msg) {
+               if (getTicketsBranch(db) == null) {
+                       createTicketsBranch(db);
+               }
+
+               DirCache newIndex = DirCache.newInCore();
+               DirCacheBuilder builder = newIndex.builder();
+               ObjectInserter inserter = db.newObjectInserter();
+
+               try {
+                       // create an index entry for the revised index
+                       final DirCacheEntry idIndexEntry = new DirCacheEntry(file);
+                       idIndexEntry.setLength(content.length());
+                       idIndexEntry.setLastModified(System.currentTimeMillis());
+                       idIndexEntry.setFileMode(FileMode.REGULAR_FILE);
+
+                       // insert new ticket index
+                       idIndexEntry.setObjectId(inserter.insert(org.eclipse.jgit.lib.Constants.OBJ_BLOB,
+                                       content.getBytes(Constants.ENCODING)));
+
+                       // add to temporary in-core index
+                       builder.add(idIndexEntry);
+
+                       Set<String> ignorePaths = new HashSet<String>();
+                       ignorePaths.add(file);
+
+                       for (DirCacheEntry entry : getTreeEntries(db, ignorePaths)) {
+                               builder.add(entry);
+                       }
+
+                       // finish temporary in-core index used for this commit
+                       builder.finish();
+
+                       // commit the change
+                       commitIndex(db, newIndex, createdBy, msg);
+
+               } catch (ConcurrentRefUpdateException e) {
+                       log.error("", e);
+               } catch (IOException e) {
+                       log.error("", e);
+               } finally {
+                       inserter.release();
+               }
+       }
+
+       /**
+        * Ensures that we have a ticket for this ticket id.
+        *
+        * @param repository
+        * @param ticketId
+        * @return true if the ticket exists
+        */
+       @Override
+       public boolean hasTicket(RepositoryModel repository, long ticketId) {
+               boolean hasTicket = false;
+               Repository db = repositoryManager.getRepository(repository.name);
+               try {
+                       RefModel ticketsBranch = getTicketsBranch(db);
+                       if (ticketsBranch == null) {
+                               return false;
+                       }
+                       String ticketPath = toTicketPath(ticketId);
+                       RevCommit tip = JGitUtils.getCommit(db, BRANCH);
+                       hasTicket = !JGitUtils.getFilesInPath(db, ticketPath, tip).isEmpty();
+               } finally {
+                       db.close();
+               }
+               return hasTicket;
+       }
+
+       /**
+        * Assigns a new ticket id.
+        *
+        * @param repository
+        * @return a new long id
+        */
+       @Override
+       public synchronized long assignNewId(RepositoryModel repository) {
+               long newId = 0L;
+               Repository db = repositoryManager.getRepository(repository.name);
+               try {
+                       if (getTicketsBranch(db) == null) {
+                               createTicketsBranch(db);
+                       }
+
+                       // identify current highest ticket id by scanning the paths in the tip tree
+                       if (!lastAssignedId.containsKey(repository.name)) {
+                               lastAssignedId.put(repository.name, new AtomicLong(0));
+                       }
+                       AtomicLong lastId = lastAssignedId.get(repository.name);
+                       if (lastId.get() <= 0) {
+                               List<PathModel> paths = JGitUtils.getDocuments(db, Arrays.asList("json"), BRANCH);
+                               for (PathModel path : paths) {
+                                       String name = path.name.substring(path.name.lastIndexOf('/') + 1);
+                                       if (!JOURNAL.equals(name)) {
+                                               continue;
+                                       }
+                                       String tid = path.path.split("/")[2];
+                                       long ticketId = Long.parseLong(tid);
+                                       if (ticketId > lastId.get()) {
+                                               lastId.set(ticketId);
+                                       }
+                               }
+                       }
+
+                       // assign the id and touch an empty journal to hold it's place
+                       newId = lastId.incrementAndGet();
+                       String journalPath = toTicketPath(newId) + "/" + JOURNAL;
+                       writeTicketsFile(db, journalPath, "", "gitblit", "assigned id #" + newId);
+               } finally {
+                       db.close();
+               }
+               return newId;
+       }
+
+       /**
+        * Returns all the tickets in the repository. Querying tickets from the
+        * repository requires deserializing all tickets. This is an  expensive
+        * process and not recommended. Tickets are indexed by Lucene and queries
+        * should be executed against that index.
+        *
+        * @param repository
+        * @param filter
+        *            optional filter to only return matching results
+        * @return a list of tickets
+        */
+       @Override
+       public List<TicketModel> getTickets(RepositoryModel repository, TicketFilter filter) {
+               List<TicketModel> list = new ArrayList<TicketModel>();
+
+               Repository db = repositoryManager.getRepository(repository.name);
+               try {
+                       RefModel ticketsBranch = getTicketsBranch(db);
+                       if (ticketsBranch == null) {
+                               return list;
+                       }
+
+                       // Collect the set of all json files
+                       List<PathModel> paths = JGitUtils.getDocuments(db, Arrays.asList("json"), BRANCH);
+
+                       // Deserialize each ticket and optionally filter out unwanted tickets
+                       for (PathModel path : paths) {
+                               String name = path.name.substring(path.name.lastIndexOf('/') + 1);
+                               if (!JOURNAL.equals(name)) {
+                                       continue;
+                               }
+                               String json = readTicketsFile(db, path.path);
+                               if (StringUtils.isEmpty(json)) {
+                                       // journal was touched but no changes were written
+                                       continue;
+                               }
+                               try {
+                                       // Reconstruct ticketId from the path
+                                       // id/26/326/journal.json
+                                       String tid = path.path.split("/")[2];
+                                       long ticketId = Long.parseLong(tid);
+                                       List<Change> changes = TicketSerializer.deserializeJournal(json);
+                                       if (ArrayUtils.isEmpty(changes)) {
+                                               log.warn("Empty journal for {}:{}", repository, path.path);
+                                               continue;
+                                       }
+                                       TicketModel ticket = TicketModel.buildTicket(changes);
+                                       ticket.project = repository.projectPath;
+                                       ticket.repository = repository.name;
+                                       ticket.number = ticketId;
+
+                                       // add the ticket, conditionally, to the list
+                                       if (filter == null) {
+                                               list.add(ticket);
+                                       } else {
+                                               if (filter.accept(ticket)) {
+                                                       list.add(ticket);
+                                               }
+                                       }
+                               } catch (Exception e) {
+                                       log.error("failed to deserialize {}/{}\n{}",
+                                                       new Object [] { repository, path.path, e.getMessage()});
+                                       log.error(null, e);
+                               }
+                       }
+
+                       // sort the tickets by creation
+                       Collections.sort(list);
+                       return list;
+               } finally {
+                       db.close();
+               }
+       }
+
+       /**
+        * Retrieves the ticket from the repository by first looking-up the changeId
+        * associated with the ticketId.
+        *
+        * @param repository
+        * @param ticketId
+        * @return a ticket, if it exists, otherwise null
+        */
+       @Override
+       protected TicketModel getTicketImpl(RepositoryModel repository, long ticketId) {
+               Repository db = repositoryManager.getRepository(repository.name);
+               try {
+                       List<Change> changes = getJournal(db, ticketId);
+                       if (ArrayUtils.isEmpty(changes)) {
+                               log.warn("Empty journal for {}:{}", repository, ticketId);
+                               return null;
+                       }
+                       TicketModel ticket = TicketModel.buildTicket(changes);
+                       if (ticket != null) {
+                               ticket.project = repository.projectPath;
+                               ticket.repository = repository.name;
+                               ticket.number = ticketId;
+                       }
+                       return ticket;
+               } finally {
+                       db.close();
+               }
+       }
+
+       /**
+        * Returns the journal for the specified ticket.
+        *
+        * @param db
+        * @param ticketId
+        * @return a list of changes
+        */
+       private List<Change> getJournal(Repository db, long ticketId) {
+               RefModel ticketsBranch = getTicketsBranch(db);
+               if (ticketsBranch == null) {
+                       return new ArrayList<Change>();
+               }
+
+               if (ticketId <= 0L) {
+                       return new ArrayList<Change>();
+               }
+
+               String journalPath = toTicketPath(ticketId) + "/" + JOURNAL;
+               String json = readTicketsFile(db, journalPath);
+               if (StringUtils.isEmpty(json)) {
+                       return new ArrayList<Change>();
+               }
+               List<Change> list = TicketSerializer.deserializeJournal(json);
+               return list;
+       }
+
+       @Override
+       public boolean supportsAttachments() {
+               return true;
+       }
+
+       /**
+        * Retrieves the specified attachment from a ticket.
+        *
+        * @param repository
+        * @param ticketId
+        * @param filename
+        * @return an attachment, if found, null otherwise
+        */
+       @Override
+       public Attachment getAttachment(RepositoryModel repository, long ticketId, String filename) {
+               if (ticketId <= 0L) {
+                       return null;
+               }
+
+               // deserialize the ticket model so that we have the attachment metadata
+               TicketModel ticket = getTicket(repository, ticketId);
+               Attachment attachment = ticket.getAttachment(filename);
+
+               // attachment not found
+               if (attachment == null) {
+                       return null;
+               }
+
+               // retrieve the attachment content
+               Repository db = repositoryManager.getRepository(repository.name);
+               try {
+                       String attachmentPath = toAttachmentPath(ticketId, attachment.name);
+                       RevTree tree = JGitUtils.getCommit(db, BRANCH).getTree();
+                       byte[] content = JGitUtils.getByteContent(db, tree, attachmentPath, false);
+                       attachment.content = content;
+                       attachment.size = content.length;
+                       return attachment;
+               } finally {
+                       db.close();
+               }
+       }
+
+       /**
+        * Deletes a ticket from the repository.
+        *
+        * @param ticket
+        * @return true if successful
+        */
+       @Override
+       protected synchronized boolean deleteTicketImpl(RepositoryModel repository, TicketModel ticket, String deletedBy) {
+               if (ticket == null) {
+                       throw new RuntimeException("must specify a ticket!");
+               }
+
+               boolean success = false;
+               Repository db = repositoryManager.getRepository(ticket.repository);
+               try {
+                       RefModel ticketsBranch = getTicketsBranch(db);
+
+                       if (ticketsBranch == null) {
+                               throw new RuntimeException(BRANCH + " does not exist!");
+                       }
+                       String ticketPath = toTicketPath(ticket.number);
+
+                       TreeWalk treeWalk = null;
+                       try {
+                               ObjectId treeId = db.resolve(BRANCH + "^{tree}");
+
+                               // Create the in-memory index of the new/updated ticket
+                               DirCache index = DirCache.newInCore();
+                               DirCacheBuilder builder = index.builder();
+
+                               // Traverse HEAD to add all other paths
+                               treeWalk = new TreeWalk(db);
+                               int hIdx = -1;
+                               if (treeId != null) {
+                                       hIdx = treeWalk.addTree(treeId);
+                               }
+                               treeWalk.setRecursive(true);
+                               while (treeWalk.next()) {
+                                       String path = treeWalk.getPathString();
+                                       CanonicalTreeParser hTree = null;
+                                       if (hIdx != -1) {
+                                               hTree = treeWalk.getTree(hIdx, CanonicalTreeParser.class);
+                                       }
+                                       if (!path.startsWith(ticketPath)) {
+                                               // add entries from HEAD for all other paths
+                                               if (hTree != null) {
+                                                       final DirCacheEntry entry = new DirCacheEntry(path);
+                                                       entry.setObjectId(hTree.getEntryObjectId());
+                                                       entry.setFileMode(hTree.getEntryFileMode());
+
+                                                       // add to temporary in-core index
+                                                       builder.add(entry);
+                                               }
+                                       }
+                               }
+
+                               // finish temporary in-core index used for this commit
+                               builder.finish();
+
+                               success = commitIndex(db, index, deletedBy, "- " + ticket.number);
+
+                       } catch (Throwable t) {
+                               log.error(MessageFormat.format("Failed to delete ticket {0,number,0} from {1}",
+                                               ticket.number, db.getDirectory()), t);
+                       } finally {
+                               // release the treewalk
+                               treeWalk.release();
+                       }
+               } finally {
+                       db.close();
+               }
+               return success;
+       }
+
+       /**
+        * Commit a ticket change to the repository.
+        *
+        * @param repository
+        * @param ticketId
+        * @param change
+        * @return true, if the change was committed
+        */
+       @Override
+       protected synchronized boolean commitChangeImpl(RepositoryModel repository, long ticketId, Change change) {
+               boolean success = false;
+
+               Repository db = repositoryManager.getRepository(repository.name);
+               try {
+                       DirCache index = createIndex(db, ticketId, change);
+                       success = commitIndex(db, index, change.author, "#" + ticketId);
+
+               } catch (Throwable t) {
+                       log.error(MessageFormat.format("Failed to commit ticket {0,number,0} to {1}",
+                                       ticketId, db.getDirectory()), t);
+               } finally {
+                       db.close();
+               }
+               return success;
+       }
+
+       /**
+        * Creates an in-memory index of the ticket change.
+        *
+        * @param changeId
+        * @param change
+        * @return an in-memory index
+        * @throws IOException
+        */
+       private DirCache createIndex(Repository db, long ticketId, Change change)
+                       throws IOException, ClassNotFoundException, NoSuchFieldException {
+
+               String ticketPath = toTicketPath(ticketId);
+               DirCache newIndex = DirCache.newInCore();
+               DirCacheBuilder builder = newIndex.builder();
+               ObjectInserter inserter = db.newObjectInserter();
+
+               Set<String> ignorePaths = new TreeSet<String>();
+               try {
+                       // create/update the journal
+                       // exclude the attachment content
+                       List<Change> changes = getJournal(db, ticketId);
+                       changes.add(change);
+                       String journal = TicketSerializer.serializeJournal(changes).trim();
+
+                       byte [] journalBytes = journal.getBytes(Constants.ENCODING);
+                       String journalPath = ticketPath + "/" + JOURNAL;
+                       final DirCacheEntry journalEntry = new DirCacheEntry(journalPath);
+                       journalEntry.setLength(journalBytes.length);
+                       journalEntry.setLastModified(change.date.getTime());
+                       journalEntry.setFileMode(FileMode.REGULAR_FILE);
+                       journalEntry.setObjectId(inserter.insert(org.eclipse.jgit.lib.Constants.OBJ_BLOB, journalBytes));
+
+                       // add journal to index
+                       builder.add(journalEntry);
+                       ignorePaths.add(journalEntry.getPathString());
+
+                       // Add any attachments to the index
+                       if (change.hasAttachments()) {
+                               for (Attachment attachment : change.attachments) {
+                                       // build a path name for the attachment and mark as ignored
+                                       String path = toAttachmentPath(ticketId, attachment.name);
+                                       ignorePaths.add(path);
+
+                                       // create an index entry for this attachment
+                                       final DirCacheEntry entry = new DirCacheEntry(path);
+                                       entry.setLength(attachment.content.length);
+                                       entry.setLastModified(change.date.getTime());
+                                       entry.setFileMode(FileMode.REGULAR_FILE);
+
+                                       // insert object
+                                       entry.setObjectId(inserter.insert(org.eclipse.jgit.lib.Constants.OBJ_BLOB, attachment.content));
+
+                                       // add to temporary in-core index
+                                       builder.add(entry);
+                               }
+                       }
+
+                       for (DirCacheEntry entry : getTreeEntries(db, ignorePaths)) {
+                               builder.add(entry);
+                       }
+
+                       // finish the index
+                       builder.finish();
+               } finally {
+                       inserter.release();
+               }
+               return newIndex;
+       }
+
+       /**
+        * Returns all tree entries that do not match the ignore paths.
+        *
+        * @param db
+        * @param ignorePaths
+        * @param dcBuilder
+        * @throws IOException
+        */
+       private List<DirCacheEntry> getTreeEntries(Repository db, Collection<String> ignorePaths) throws IOException {
+               List<DirCacheEntry> list = new ArrayList<DirCacheEntry>();
+               TreeWalk tw = null;
+               try {
+                       tw = new TreeWalk(db);
+                       ObjectId treeId = db.resolve(BRANCH + "^{tree}");
+                       int hIdx = tw.addTree(treeId);
+                       tw.setRecursive(true);
+
+                       while (tw.next()) {
+                               String path = tw.getPathString();
+                               CanonicalTreeParser hTree = null;
+                               if (hIdx != -1) {
+                                       hTree = tw.getTree(hIdx, CanonicalTreeParser.class);
+                               }
+                               if (!ignorePaths.contains(path)) {
+                                       // add all other tree entries
+                                       if (hTree != null) {
+                                               final DirCacheEntry entry = new DirCacheEntry(path);
+                                               entry.setObjectId(hTree.getEntryObjectId());
+                                               entry.setFileMode(hTree.getEntryFileMode());
+                                               list.add(entry);
+                                       }
+                               }
+                       }
+               } finally {
+                       if (tw != null) {
+                               tw.release();
+                       }
+               }
+               return list;
+       }
+
+       private boolean commitIndex(Repository db, DirCache index, String author, String message) throws IOException, ConcurrentRefUpdateException {
+               boolean success = false;
+
+               ObjectId headId = db.resolve(BRANCH + "^{commit}");
+               if (headId == null) {
+                       // create the branch
+                       createTicketsBranch(db);
+                       headId = db.resolve(BRANCH + "^{commit}");
+               }
+               ObjectInserter odi = db.newObjectInserter();
+               try {
+                       // Create the in-memory index of the new/updated ticket
+                       ObjectId indexTreeId = index.writeTree(odi);
+
+                       // Create a commit object
+                       PersonIdent ident = new PersonIdent(author, "gitblit@localhost");
+                       CommitBuilder commit = new CommitBuilder();
+                       commit.setAuthor(ident);
+                       commit.setCommitter(ident);
+                       commit.setEncoding(Constants.ENCODING);
+                       commit.setMessage(message);
+                       commit.setParentId(headId);
+                       commit.setTreeId(indexTreeId);
+
+                       // Insert the commit into the repository
+                       ObjectId commitId = odi.insert(commit);
+                       odi.flush();
+
+                       RevWalk revWalk = new RevWalk(db);
+                       try {
+                               RevCommit revCommit = revWalk.parseCommit(commitId);
+                               RefUpdate ru = db.updateRef(BRANCH);
+                               ru.setNewObjectId(commitId);
+                               ru.setExpectedOldObjectId(headId);
+                               ru.setRefLogMessage("commit: " + revCommit.getShortMessage(), false);
+                               Result rc = ru.forceUpdate();
+                               switch (rc) {
+                               case NEW:
+                               case FORCED:
+                               case FAST_FORWARD:
+                                       success = true;
+                                       break;
+                               case REJECTED:
+                               case LOCK_FAILURE:
+                                       throw new ConcurrentRefUpdateException(JGitText.get().couldNotLockHEAD,
+                                                       ru.getRef(), rc);
+                               default:
+                                       throw new JGitInternalException(MessageFormat.format(
+                                                       JGitText.get().updatingRefFailed, BRANCH, commitId.toString(),
+                                                       rc));
+                               }
+                       } finally {
+                               revWalk.release();
+                       }
+               } finally {
+                       odi.release();
+               }
+               return success;
+       }
+
+       @Override
+       protected boolean deleteAllImpl(RepositoryModel repository) {
+               Repository db = repositoryManager.getRepository(repository.name);
+               try {
+                       RefModel branch = getTicketsBranch(db);
+                       if (branch != null) {
+                               return JGitUtils.deleteBranchRef(db, BRANCH);
+                       }
+                       return true;
+               } catch (Exception e) {
+                       log.error(null, e);
+               } finally {
+                       db.close();
+               }
+               return false;
+       }
+
+       @Override
+       protected boolean renameImpl(RepositoryModel oldRepository, RepositoryModel newRepository) {
+               return true;
+       }
+
+       @Override
+       public String toString() {
+               return getClass().getSimpleName();
+       }
+}
diff --git a/src/main/java/com/gitblit/tickets/FileTicketService.java b/src/main/java/com/gitblit/tickets/FileTicketService.java
new file mode 100644 (file)
index 0000000..8375a2b
--- /dev/null
@@ -0,0 +1,467 @@
+/*
+ * Copyright 2014 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.tickets;
+
+import java.io.File;
+import java.io.IOException;
+import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+
+import javax.inject.Inject;
+
+import org.eclipse.jgit.lib.Repository;
+
+import com.gitblit.Constants;
+import com.gitblit.manager.INotificationManager;
+import com.gitblit.manager.IRepositoryManager;
+import com.gitblit.manager.IRuntimeManager;
+import com.gitblit.manager.IUserManager;
+import com.gitblit.models.RepositoryModel;
+import com.gitblit.models.TicketModel;
+import com.gitblit.models.TicketModel.Attachment;
+import com.gitblit.models.TicketModel.Change;
+import com.gitblit.utils.ArrayUtils;
+import com.gitblit.utils.FileUtils;
+import com.gitblit.utils.StringUtils;
+
+/**
+ * Implementation of a ticket service based on a directory within the repository.
+ * All tickets are serialized as a list of JSON changes and persisted in a hashed
+ * directory structure, similar to the standard git loose object structure.
+ *
+ * @author James Moger
+ *
+ */
+public class FileTicketService extends ITicketService {
+
+       private static final String JOURNAL = "journal.json";
+
+       private static final String TICKETS_PATH = "tickets/";
+
+       private final Map<String, AtomicLong> lastAssignedId;
+
+       @Inject
+       public FileTicketService(
+                       IRuntimeManager runtimeManager,
+                       INotificationManager notificationManager,
+                       IUserManager userManager,
+                       IRepositoryManager repositoryManager) {
+
+               super(runtimeManager,
+                               notificationManager,
+                               userManager,
+                               repositoryManager);
+
+               lastAssignedId = new ConcurrentHashMap<String, AtomicLong>();
+       }
+
+       @Override
+       public FileTicketService start() {
+               return this;
+       }
+
+       @Override
+       protected void resetCachesImpl() {
+               lastAssignedId.clear();
+       }
+
+       @Override
+       protected void resetCachesImpl(RepositoryModel repository) {
+               if (lastAssignedId.containsKey(repository.name)) {
+                       lastAssignedId.get(repository.name).set(0);
+               }
+       }
+
+       @Override
+       protected void close() {
+       }
+
+       /**
+        * Returns the ticket path. This follows the same scheme as Git's object
+        * store path where the first two characters of the hash id are the root
+        * folder with the remaining characters as a subfolder within that folder.
+        *
+        * @param ticketId
+        * @return the root path of the ticket content in the ticket directory
+        */
+       private String toTicketPath(long ticketId) {
+               StringBuilder sb = new StringBuilder();
+               sb.append(TICKETS_PATH);
+               long m = ticketId % 100L;
+               if (m < 10) {
+                       sb.append('0');
+               }
+               sb.append(m);
+               sb.append('/');
+               sb.append(ticketId);
+               return sb.toString();
+       }
+
+       /**
+        * Returns the path to the attachment for the specified ticket.
+        *
+        * @param ticketId
+        * @param filename
+        * @return the path to the specified attachment
+        */
+       private String toAttachmentPath(long ticketId, String filename) {
+               return toTicketPath(ticketId) + "/attachments/" + filename;
+       }
+
+       /**
+        * Ensures that we have a ticket for this ticket id.
+        *
+        * @param repository
+        * @param ticketId
+        * @return true if the ticket exists
+        */
+       @Override
+       public boolean hasTicket(RepositoryModel repository, long ticketId) {
+               boolean hasTicket = false;
+               Repository db = repositoryManager.getRepository(repository.name);
+               try {
+                       String journalPath = toTicketPath(ticketId) + "/" + JOURNAL;
+                       hasTicket = new File(db.getDirectory(), journalPath).exists();
+               } finally {
+                       db.close();
+               }
+               return hasTicket;
+       }
+
+       /**
+        * Assigns a new ticket id.
+        *
+        * @param repository
+        * @return a new long id
+        */
+       @Override
+       public synchronized long assignNewId(RepositoryModel repository) {
+               long newId = 0L;
+               Repository db = repositoryManager.getRepository(repository.name);
+               try {
+                       if (!lastAssignedId.containsKey(repository.name)) {
+                               lastAssignedId.put(repository.name, new AtomicLong(0));
+                       }
+                       AtomicLong lastId = lastAssignedId.get(repository.name);
+                       if (lastId.get() <= 0) {
+                               // identify current highest ticket id by scanning the paths in the tip tree
+                               File dir = new File(db.getDirectory(), TICKETS_PATH);
+                               dir.mkdirs();
+                               List<File> journals = findAll(dir, JOURNAL);
+                               for (File journal : journals) {
+                                       // Reconstruct ticketId from the path
+                                       // id/26/326/journal.json
+                                       String path = FileUtils.getRelativePath(dir, journal);
+                                       String tid = path.split("/")[1];
+                                       long ticketId = Long.parseLong(tid);
+                                       if (ticketId > lastId.get()) {
+                                               lastId.set(ticketId);
+                                       }
+                               }
+                       }
+
+                       // assign the id and touch an empty journal to hold it's place
+                       newId = lastId.incrementAndGet();
+                       String journalPath = toTicketPath(newId) + "/" + JOURNAL;
+                       File journal = new File(db.getDirectory(), journalPath);
+                       journal.getParentFile().mkdirs();
+                       journal.createNewFile();
+               } catch (IOException e) {
+                       log.error("failed to assign ticket id", e);
+                       return 0L;
+               } finally {
+                       db.close();
+               }
+               return newId;
+       }
+
+       /**
+        * Returns all the tickets in the repository. Querying tickets from the
+        * repository requires deserializing all tickets. This is an  expensive
+        * process and not recommended. Tickets are indexed by Lucene and queries
+        * should be executed against that index.
+        *
+        * @param repository
+        * @param filter
+        *            optional filter to only return matching results
+        * @return a list of tickets
+        */
+       @Override
+       public List<TicketModel> getTickets(RepositoryModel repository, TicketFilter filter) {
+               List<TicketModel> list = new ArrayList<TicketModel>();
+
+               Repository db = repositoryManager.getRepository(repository.name);
+               try {
+                       // Collect the set of all json files
+                       File dir = new File(db.getDirectory(), TICKETS_PATH);
+                       List<File> journals = findAll(dir, JOURNAL);
+
+                       // Deserialize each ticket and optionally filter out unwanted tickets
+                       for (File journal : journals) {
+                               String json = null;
+                               try {
+                                       json = new String(FileUtils.readContent(journal), Constants.ENCODING);
+                               } catch (Exception e) {
+                                       log.error(null, e);
+                               }
+                               if (StringUtils.isEmpty(json)) {
+                                       // journal was touched but no changes were written
+                                       continue;
+                               }
+                               try {
+                                       // Reconstruct ticketId from the path
+                                       // id/26/326/journal.json
+                                       String path = FileUtils.getRelativePath(dir, journal);
+                                       String tid = path.split("/")[1];
+                                       long ticketId = Long.parseLong(tid);
+                                       List<Change> changes = TicketSerializer.deserializeJournal(json);
+                                       if (ArrayUtils.isEmpty(changes)) {
+                                               log.warn("Empty journal for {}:{}", repository, journal);
+                                               continue;
+                                       }
+                                       TicketModel ticket = TicketModel.buildTicket(changes);
+                                       ticket.project = repository.projectPath;
+                                       ticket.repository = repository.name;
+                                       ticket.number = ticketId;
+
+                                       // add the ticket, conditionally, to the list
+                                       if (filter == null) {
+                                               list.add(ticket);
+                                       } else {
+                                               if (filter.accept(ticket)) {
+                                                       list.add(ticket);
+                                               }
+                                       }
+                               } catch (Exception e) {
+                                       log.error("failed to deserialize {}/{}\n{}",
+                                                       new Object [] { repository, journal, e.getMessage()});
+                                       log.error(null, e);
+                               }
+                       }
+
+                       // sort the tickets by creation
+                       Collections.sort(list);
+                       return list;
+               } finally {
+                       db.close();
+               }
+       }
+
+       private List<File> findAll(File dir, String filename) {
+               List<File> list = new ArrayList<File>();
+               for (File file : dir.listFiles()) {
+                       if (file.isDirectory()) {
+                               list.addAll(findAll(file, filename));
+                       } else if (file.isFile()) {
+                               if (file.getName().equals(filename)) {
+                                       list.add(file);
+                               }
+                       }
+               }
+               return list;
+       }
+
+       /**
+        * Retrieves the ticket from the repository by first looking-up the changeId
+        * associated with the ticketId.
+        *
+        * @param repository
+        * @param ticketId
+        * @return a ticket, if it exists, otherwise null
+        */
+       @Override
+       protected TicketModel getTicketImpl(RepositoryModel repository, long ticketId) {
+               Repository db = repositoryManager.getRepository(repository.name);
+               try {
+                       List<Change> changes = getJournal(db, ticketId);
+                       if (ArrayUtils.isEmpty(changes)) {
+                               log.warn("Empty journal for {}:{}", repository, ticketId);
+                               return null;
+                       }
+                       TicketModel ticket = TicketModel.buildTicket(changes);
+                       if (ticket != null) {
+                               ticket.project = repository.projectPath;
+                               ticket.repository = repository.name;
+                               ticket.number = ticketId;
+                       }
+                       return ticket;
+               } finally {
+                       db.close();
+               }
+       }
+
+       /**
+        * Returns the journal for the specified ticket.
+        *
+        * @param db
+        * @param ticketId
+        * @return a list of changes
+        */
+       private List<Change> getJournal(Repository db, long ticketId) {
+               if (ticketId <= 0L) {
+                       return new ArrayList<Change>();
+               }
+
+               String journalPath = toTicketPath(ticketId) + "/" + JOURNAL;
+               File journal = new File(db.getDirectory(), journalPath);
+               if (!journal.exists()) {
+                       return new ArrayList<Change>();
+               }
+
+               String json = null;
+               try {
+                       json = new String(FileUtils.readContent(journal), Constants.ENCODING);
+               } catch (Exception e) {
+                       log.error(null, e);
+               }
+               if (StringUtils.isEmpty(json)) {
+                       return new ArrayList<Change>();
+               }
+               List<Change> list = TicketSerializer.deserializeJournal(json);
+               return list;
+       }
+
+       @Override
+       public boolean supportsAttachments() {
+               return true;
+       }
+
+       /**
+        * Retrieves the specified attachment from a ticket.
+        *
+        * @param repository
+        * @param ticketId
+        * @param filename
+        * @return an attachment, if found, null otherwise
+        */
+       @Override
+       public Attachment getAttachment(RepositoryModel repository, long ticketId, String filename) {
+               if (ticketId <= 0L) {
+                       return null;
+               }
+
+               // deserialize the ticket model so that we have the attachment metadata
+               TicketModel ticket = getTicket(repository, ticketId);
+               Attachment attachment = ticket.getAttachment(filename);
+
+               // attachment not found
+               if (attachment == null) {
+                       return null;
+               }
+
+               // retrieve the attachment content
+               Repository db = repositoryManager.getRepository(repository.name);
+               try {
+                       String attachmentPath = toAttachmentPath(ticketId, attachment.name);
+                       File file = new File(db.getDirectory(), attachmentPath);
+                       if (file.exists()) {
+                               attachment.content = FileUtils.readContent(file);
+                               attachment.size = attachment.content.length;
+                       }
+                       return attachment;
+               } finally {
+                       db.close();
+               }
+       }
+
+       /**
+        * Deletes a ticket from the repository.
+        *
+        * @param ticket
+        * @return true if successful
+        */
+       @Override
+       protected synchronized boolean deleteTicketImpl(RepositoryModel repository, TicketModel ticket, String deletedBy) {
+               if (ticket == null) {
+                       throw new RuntimeException("must specify a ticket!");
+               }
+
+               boolean success = false;
+               Repository db = repositoryManager.getRepository(ticket.repository);
+               try {
+                       String ticketPath = toTicketPath(ticket.number);
+                       File dir = new File(db.getDirectory(), ticketPath);
+                       if (dir.exists()) {
+                               success = FileUtils.delete(dir);
+                       }
+                       success = true;
+               } finally {
+                       db.close();
+               }
+               return success;
+       }
+
+       /**
+        * Commit a ticket change to the repository.
+        *
+        * @param repository
+        * @param ticketId
+        * @param change
+        * @return true, if the change was committed
+        */
+       @Override
+       protected synchronized boolean commitChangeImpl(RepositoryModel repository, long ticketId, Change change) {
+               boolean success = false;
+
+               Repository db = repositoryManager.getRepository(repository.name);
+               try {
+                       List<Change> changes = getJournal(db, ticketId);
+                       changes.add(change);
+                       String journal = TicketSerializer.serializeJournal(changes).trim();
+
+                       String journalPath = toTicketPath(ticketId) + "/" + JOURNAL;
+                       File file = new File(db.getDirectory(), journalPath);
+                       file.getParentFile().mkdirs();
+                       FileUtils.writeContent(file, journal);
+                       success = true;
+               } catch (Throwable t) {
+                       log.error(MessageFormat.format("Failed to commit ticket {0,number,0} to {1}",
+                                       ticketId, db.getDirectory()), t);
+               } finally {
+                       db.close();
+               }
+               return success;
+       }
+
+       @Override
+       protected boolean deleteAllImpl(RepositoryModel repository) {
+               Repository db = repositoryManager.getRepository(repository.name);
+               try {
+                       File dir = new File(db.getDirectory(), TICKETS_PATH);
+                       return FileUtils.delete(dir);
+               } catch (Exception e) {
+                       log.error(null, e);
+               } finally {
+                       db.close();
+               }
+               return false;
+       }
+
+       @Override
+       protected boolean renameImpl(RepositoryModel oldRepository, RepositoryModel newRepository) {
+               return true;
+       }
+
+       @Override
+       public String toString() {
+               return getClass().getSimpleName();
+       }
+}
diff --git a/src/main/java/com/gitblit/tickets/ITicketService.java b/src/main/java/com/gitblit/tickets/ITicketService.java
new file mode 100644 (file)
index 0000000..d04cd5e
--- /dev/null
@@ -0,0 +1,1088 @@
+/*
+ * Copyright 2013 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.tickets;
+
+import java.io.IOException;
+import java.text.MessageFormat;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.BitSet;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.gitblit.IStoredSettings;
+import com.gitblit.Keys;
+import com.gitblit.manager.INotificationManager;
+import com.gitblit.manager.IRepositoryManager;
+import com.gitblit.manager.IRuntimeManager;
+import com.gitblit.manager.IUserManager;
+import com.gitblit.models.RepositoryModel;
+import com.gitblit.models.TicketModel;
+import com.gitblit.models.TicketModel.Attachment;
+import com.gitblit.models.TicketModel.Change;
+import com.gitblit.models.TicketModel.Field;
+import com.gitblit.models.TicketModel.Patchset;
+import com.gitblit.models.TicketModel.Status;
+import com.gitblit.tickets.TicketIndexer.Lucene;
+import com.gitblit.utils.DiffUtils;
+import com.gitblit.utils.DiffUtils.DiffStat;
+import com.gitblit.utils.StringUtils;
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+
+/**
+ * Abstract parent class of a ticket service that stubs out required methods
+ * and transparently handles Lucene indexing.
+ *
+ * @author James Moger
+ *
+ */
+public abstract class ITicketService {
+
+       private static final String LABEL = "label";
+
+       private static final String MILESTONE = "milestone";
+
+       private static final String STATUS = "status";
+
+       private static final String COLOR = "color";
+
+       private static final String DUE = "due";
+
+       private static final String DUE_DATE_PATTERN = "yyyy-MM-dd";
+
+       /**
+        * Object filter interface to querying against all available ticket models.
+        */
+       public interface TicketFilter {
+
+               boolean accept(TicketModel ticket);
+       }
+
+       protected final Logger log;
+
+       protected final IStoredSettings settings;
+
+       protected final IRuntimeManager runtimeManager;
+
+       protected final INotificationManager notificationManager;
+
+       protected final IUserManager userManager;
+
+       protected final IRepositoryManager repositoryManager;
+
+       protected final TicketIndexer indexer;
+
+       private final Cache<TicketKey, TicketModel> ticketsCache;
+
+       private final Map<String, List<TicketLabel>> labelsCache;
+
+       private final Map<String, List<TicketMilestone>> milestonesCache;
+
+       private static class TicketKey {
+               final String repository;
+               final long ticketId;
+
+               TicketKey(RepositoryModel repository, long ticketId) {
+                       this.repository = repository.name;
+                       this.ticketId = ticketId;
+               }
+
+               @Override
+               public int hashCode() {
+                       return (repository + ticketId).hashCode();
+               }
+
+               @Override
+               public boolean equals(Object o) {
+                       if (o instanceof TicketKey) {
+                               return o.hashCode() == hashCode();
+                       }
+                       return false;
+               }
+
+               @Override
+               public String toString() {
+                       return repository + ":" + ticketId;
+               }
+       }
+
+
+       /**
+        * Creates a ticket service.
+        */
+       public ITicketService(
+                       IRuntimeManager runtimeManager,
+                       INotificationManager notificationManager,
+                       IUserManager userManager,
+                       IRepositoryManager repositoryManager) {
+
+               this.log = LoggerFactory.getLogger(getClass());
+               this.settings = runtimeManager.getSettings();
+               this.runtimeManager = runtimeManager;
+               this.notificationManager = notificationManager;
+               this.userManager = userManager;
+               this.repositoryManager = repositoryManager;
+
+               this.indexer = new TicketIndexer(runtimeManager);
+
+               CacheBuilder<Object, Object> cb = CacheBuilder.newBuilder();
+               this.ticketsCache = cb
+                               .maximumSize(1000)
+                               .expireAfterAccess(30, TimeUnit.MINUTES)
+                               .build();
+
+               this.labelsCache = new ConcurrentHashMap<String, List<TicketLabel>>();
+               this.milestonesCache = new ConcurrentHashMap<String, List<TicketMilestone>>();
+       }
+
+       /**
+        * Start the service.
+        *
+        */
+       public abstract ITicketService start();
+
+       /**
+        * Stop the service.
+        *
+        */
+       public final ITicketService stop() {
+               indexer.close();
+               ticketsCache.invalidateAll();
+               repositoryManager.closeAll();
+               close();
+               return this;
+       }
+
+       /**
+        * Creates a ticket notifier.  The ticket notifier is not thread-safe!
+        *
+        */
+       public TicketNotifier createNotifier() {
+               return new TicketNotifier(
+                               runtimeManager,
+                               notificationManager,
+                               userManager,
+                               repositoryManager,
+                               this);
+       }
+
+       /**
+        * Returns the ready status of the ticket service.
+        *
+        * @return true if the ticket service is ready
+        */
+       public boolean isReady() {
+               return true;
+       }
+
+       /**
+        * Returns true if the new patchsets can be accepted for this repository.
+        *
+        * @param repository
+        * @return true if patchsets are being accepted
+        */
+       public boolean isAcceptingNewPatchsets(RepositoryModel repository) {
+               return isReady()
+                               && settings.getBoolean(Keys.tickets.acceptNewPatchsets, true)
+                               && repository.acceptNewPatchsets
+                               && isAcceptingTicketUpdates(repository);
+       }
+
+       /**
+        * Returns true if new tickets can be manually created for this repository.
+        * This is separate from accepting patchsets.
+        *
+        * @param repository
+        * @return true if tickets are being accepted
+        */
+       public boolean isAcceptingNewTickets(RepositoryModel repository) {
+               return isReady()
+                               && settings.getBoolean(Keys.tickets.acceptNewTickets, true)
+                               && repository.acceptNewTickets
+                               && isAcceptingTicketUpdates(repository);
+       }
+
+       /**
+        * Returns true if ticket updates are allowed for this repository.
+        *
+        * @param repository
+        * @return true if tickets are allowed to be updated
+        */
+       public boolean isAcceptingTicketUpdates(RepositoryModel repository) {
+               return isReady()
+                               && repository.isBare
+                               && !repository.isFrozen
+                               && !repository.isMirror;
+       }
+
+       /**
+        * Returns true if the repository has any tickets
+        * @param repository
+        * @return true if the repository has tickets
+        */
+       public boolean hasTickets(RepositoryModel repository) {
+               return indexer.hasTickets(repository);
+       }
+
+       /**
+        * Closes any open resources used by this service.
+        */
+       protected abstract void close();
+
+       /**
+        * Reset all caches in the service.
+        */
+       public final synchronized void resetCaches() {
+               ticketsCache.invalidateAll();
+               labelsCache.clear();
+               milestonesCache.clear();
+               resetCachesImpl();
+       }
+
+       protected abstract void resetCachesImpl();
+
+       /**
+        * Reset any caches for the repository in the service.
+        */
+       public final synchronized void resetCaches(RepositoryModel repository) {
+               List<TicketKey> repoKeys = new ArrayList<TicketKey>();
+               for (TicketKey key : ticketsCache.asMap().keySet()) {
+                       if (key.repository.equals(repository.name)) {
+                               repoKeys.add(key);
+                       }
+               }
+               ticketsCache.invalidateAll(repoKeys);
+               labelsCache.remove(repository.name);
+               milestonesCache.remove(repository.name);
+               resetCachesImpl(repository);
+       }
+
+       protected abstract void resetCachesImpl(RepositoryModel repository);
+
+
+       /**
+        * Returns the list of labels for the repository.
+        *
+        * @param repository
+        * @return the list of labels
+        */
+       public List<TicketLabel> getLabels(RepositoryModel repository) {
+               String key = repository.name;
+               if (labelsCache.containsKey(key)) {
+                       return labelsCache.get(key);
+               }
+               List<TicketLabel> list = new ArrayList<TicketLabel>();
+               Repository db = repositoryManager.getRepository(repository.name);
+               try {
+                       StoredConfig config = db.getConfig();
+                       Set<String> names = config.getSubsections(LABEL);
+                       for (String name : names) {
+                               TicketLabel label = new TicketLabel(name);
+                               label.color = config.getString(LABEL, name, COLOR);
+                               list.add(label);
+                       }
+                       labelsCache.put(key,  Collections.unmodifiableList(list));
+               } catch (Exception e) {
+                       log.error("invalid tickets settings for " + repository, e);
+               } finally {
+                       db.close();
+               }
+               return list;
+       }
+
+       /**
+        * Returns a TicketLabel object for a given label.  If the label is not
+        * found, a ticket label object is created.
+        *
+        * @param repository
+        * @param label
+        * @return a TicketLabel
+        */
+       public TicketLabel getLabel(RepositoryModel repository, String label) {
+               for (TicketLabel tl : getLabels(repository)) {
+                       if (tl.name.equalsIgnoreCase(label)) {
+                               String q = QueryBuilder.q(Lucene.rid.matches(repository.getRID())).and(Lucene.labels.matches(label)).build();
+                               tl.tickets = indexer.queryFor(q, 1, 0, Lucene.number.name(), true);
+                               return tl;
+                       }
+               }
+               return new TicketLabel(label);
+       }
+
+       /**
+        * Creates a label.
+        *
+        * @param repository
+        * @param milestone
+        * @param createdBy
+        * @return the label
+        */
+       public synchronized TicketLabel createLabel(RepositoryModel repository, String label, String createdBy) {
+               TicketLabel lb = new TicketMilestone(label);
+               Repository db = null;
+               try {
+                       db = repositoryManager.getRepository(repository.name);
+                       StoredConfig config = db.getConfig();
+                       config.setString(LABEL, label, COLOR, lb.color);
+                       config.save();
+               } catch (IOException e) {
+                       log.error("failed to create label " + label + " in " + repository, e);
+               } finally {
+                       db.close();
+               }
+               return lb;
+       }
+
+       /**
+        * Updates a label.
+        *
+        * @param repository
+        * @param label
+        * @param createdBy
+        * @return true if the update was successful
+        */
+       public synchronized boolean updateLabel(RepositoryModel repository, TicketLabel label, String createdBy) {
+               Repository db = null;
+               try {
+                       db = repositoryManager.getRepository(repository.name);
+                       StoredConfig config = db.getConfig();
+                       config.setString(LABEL, label.name, COLOR, label.color);
+                       config.save();
+
+                       return true;
+               } catch (IOException e) {
+                       log.error("failed to update label " + label + " in " + repository, e);
+               } finally {
+                       db.close();
+               }
+               return false;
+       }
+
+       /**
+        * Renames a label.
+        *
+        * @param repository
+        * @param oldName
+        * @param newName
+        * @param createdBy
+        * @return true if the rename was successful
+        */
+       public synchronized boolean renameLabel(RepositoryModel repository, String oldName, String newName, String createdBy) {
+               if (StringUtils.isEmpty(newName)) {
+                       throw new IllegalArgumentException("new label can not be empty!");
+               }
+               Repository db = null;
+               try {
+                       db = repositoryManager.getRepository(repository.name);
+                       TicketLabel label = getLabel(repository, oldName);
+                       StoredConfig config = db.getConfig();
+                       config.unsetSection(LABEL, oldName);
+                       config.setString(LABEL, newName, COLOR, label.color);
+                       config.save();
+
+                       for (QueryResult qr : label.tickets) {
+                               Change change = new Change(createdBy);
+                               change.unlabel(oldName);
+                               change.label(newName);
+                               updateTicket(repository, qr.number, change);
+                       }
+
+                       return true;
+               } catch (IOException e) {
+                       log.error("failed to rename label " + oldName + " in " + repository, e);
+               } finally {
+                       db.close();
+               }
+               return false;
+       }
+
+       /**
+        * Deletes a label.
+        *
+        * @param repository
+        * @param label
+        * @param createdBy
+        * @return true if the delete was successful
+        */
+       public synchronized boolean deleteLabel(RepositoryModel repository, String label, String createdBy) {
+               if (StringUtils.isEmpty(label)) {
+                       throw new IllegalArgumentException("label can not be empty!");
+               }
+               Repository db = null;
+               try {
+                       db = repositoryManager.getRepository(repository.name);
+                       StoredConfig config = db.getConfig();
+                       config.unsetSection(LABEL, label);
+                       config.save();
+
+                       return true;
+               } catch (IOException e) {
+                       log.error("failed to delete label " + label + " in " + repository, e);
+               } finally {
+                       db.close();
+               }
+               return false;
+       }
+
+       /**
+        * Returns the list of milestones for the repository.
+        *
+        * @param repository
+        * @return the list of milestones
+        */
+       public List<TicketMilestone> getMilestones(RepositoryModel repository) {
+               String key = repository.name;
+               if (milestonesCache.containsKey(key)) {
+                       return milestonesCache.get(key);
+               }
+               List<TicketMilestone> list = new ArrayList<TicketMilestone>();
+               Repository db = repositoryManager.getRepository(repository.name);
+               try {
+                       StoredConfig config = db.getConfig();
+                       Set<String> names = config.getSubsections(MILESTONE);
+                       for (String name : names) {
+                               TicketMilestone milestone = new TicketMilestone(name);
+                               milestone.status = Status.fromObject(config.getString(MILESTONE, name, STATUS), milestone.status);
+                               milestone.color = config.getString(MILESTONE, name, COLOR);
+                               String due = config.getString(MILESTONE, name, DUE);
+                               if (!StringUtils.isEmpty(due)) {
+                                       try {
+                                               milestone.due = new SimpleDateFormat(DUE_DATE_PATTERN).parse(due);
+                                       } catch (ParseException e) {
+                                               log.error("failed to parse {} milestone {} due date \"{}\"",
+                                                               new Object [] { repository, name, due });
+                                       }
+                               }
+                               list.add(milestone);
+                       }
+                       milestonesCache.put(key, Collections.unmodifiableList(list));
+               } catch (Exception e) {
+                       log.error("invalid tickets settings for " + repository, e);
+               } finally {
+                       db.close();
+               }
+               return list;
+       }
+
+       /**
+        * Returns the list of milestones for the repository that match the status.
+        *
+        * @param repository
+        * @param status
+        * @return the list of milestones
+        */
+       public List<TicketMilestone> getMilestones(RepositoryModel repository, Status status) {
+               List<TicketMilestone> matches = new ArrayList<TicketMilestone>();
+               for (TicketMilestone milestone : getMilestones(repository)) {
+                       if (status == milestone.status) {
+                               matches.add(milestone);
+                       }
+               }
+               return matches;
+       }
+
+       /**
+        * Returns the specified milestone or null if the milestone does not exist.
+        *
+        * @param repository
+        * @param milestone
+        * @return the milestone or null if it does not exist
+        */
+       public TicketMilestone getMilestone(RepositoryModel repository, String milestone) {
+               for (TicketMilestone ms : getMilestones(repository)) {
+                       if (ms.name.equalsIgnoreCase(milestone)) {
+                               String q = QueryBuilder.q(Lucene.rid.matches(repository.getRID())).and(Lucene.milestone.matches(milestone)).build();
+                               ms.tickets = indexer.queryFor(q, 1, 0, Lucene.number.name(), true);
+                               return ms;
+                       }
+               }
+               return null;
+       }
+
+       /**
+        * Creates a milestone.
+        *
+        * @param repository
+        * @param milestone
+        * @param createdBy
+        * @return the milestone
+        */
+       public synchronized TicketMilestone createMilestone(RepositoryModel repository, String milestone, String createdBy) {
+               TicketMilestone ms = new TicketMilestone(milestone);
+               Repository db = null;
+               try {
+                       db = repositoryManager.getRepository(repository.name);
+                       StoredConfig config = db.getConfig();
+                       config.setString(MILESTONE, milestone, STATUS, ms.status.name());
+                       config.setString(MILESTONE, milestone, COLOR, ms.color);
+                       config.save();
+
+                       milestonesCache.remove(repository.name);
+               } catch (IOException e) {
+                       log.error("failed to create milestone " + milestone + " in " + repository, e);
+               } finally {
+                       db.close();
+               }
+               return ms;
+       }
+
+       /**
+        * Updates a milestone.
+        *
+        * @param repository
+        * @param milestone
+        * @param createdBy
+        * @return true if successful
+        */
+       public synchronized boolean updateMilestone(RepositoryModel repository, TicketMilestone milestone, String createdBy) {
+               Repository db = null;
+               try {
+                       db = repositoryManager.getRepository(repository.name);
+                       StoredConfig config = db.getConfig();
+                       config.setString(MILESTONE, milestone.name, STATUS, milestone.status.name());
+                       config.setString(MILESTONE, milestone.name, COLOR, milestone.color);
+                       if (milestone.due != null) {
+                               config.setString(MILESTONE, milestone.name, DUE,
+                                               new SimpleDateFormat(DUE_DATE_PATTERN).format(milestone.due));
+                       }
+                       config.save();
+
+                       milestonesCache.remove(repository.name);
+                       return true;
+               } catch (IOException e) {
+                       log.error("failed to update milestone " + milestone + " in " + repository, e);
+               } finally {
+                       db.close();
+               }
+               return false;
+       }
+
+       /**
+        * Renames a milestone.
+        *
+        * @param repository
+        * @param oldName
+        * @param newName
+        * @param createdBy
+        * @return true if successful
+        */
+       public synchronized boolean renameMilestone(RepositoryModel repository, String oldName, String newName, String createdBy) {
+               if (StringUtils.isEmpty(newName)) {
+                       throw new IllegalArgumentException("new milestone can not be empty!");
+               }
+               Repository db = null;
+               try {
+                       db = repositoryManager.getRepository(repository.name);
+                       TicketMilestone milestone = getMilestone(repository, oldName);
+                       StoredConfig config = db.getConfig();
+                       config.unsetSection(MILESTONE, oldName);
+                       config.setString(MILESTONE, newName, STATUS, milestone.status.name());
+                       config.setString(MILESTONE, newName, COLOR, milestone.color);
+                       if (milestone.due != null) {
+                               config.setString(MILESTONE, milestone.name, DUE,
+                                               new SimpleDateFormat(DUE_DATE_PATTERN).format(milestone.due));
+                       }
+                       config.save();
+
+                       milestonesCache.remove(repository.name);
+
+                       TicketNotifier notifier = createNotifier();
+                       for (QueryResult qr : milestone.tickets) {
+                               Change change = new Change(createdBy);
+                               change.setField(Field.milestone, newName);
+                               TicketModel ticket = updateTicket(repository, qr.number, change);
+                               notifier.queueMailing(ticket);
+                       }
+                       notifier.sendAll();
+
+                       return true;
+               } catch (IOException e) {
+                       log.error("failed to rename milestone " + oldName + " in " + repository, e);
+               } finally {
+                       db.close();
+               }
+               return false;
+       }
+       /**
+        * Deletes a milestone.
+        *
+        * @param repository
+        * @param milestone
+        * @param createdBy
+        * @return true if successful
+        */
+       public synchronized boolean deleteMilestone(RepositoryModel repository, String milestone, String createdBy) {
+               if (StringUtils.isEmpty(milestone)) {
+                       throw new IllegalArgumentException("milestone can not be empty!");
+               }
+               Repository db = null;
+               try {
+                       db = repositoryManager.getRepository(repository.name);
+                       StoredConfig config = db.getConfig();
+                       config.unsetSection(MILESTONE, milestone);
+                       config.save();
+
+                       milestonesCache.remove(repository.name);
+
+                       return true;
+               } catch (IOException e) {
+                       log.error("failed to delete milestone " + milestone + " in " + repository, e);
+               } finally {
+                       db.close();
+               }
+               return false;
+       }
+
+       /**
+        * Assigns a new ticket id.
+        *
+        * @param repository
+        * @return a new ticket id
+        */
+       public abstract long assignNewId(RepositoryModel repository);
+
+       /**
+        * Ensures that we have a ticket for this ticket id.
+        *
+        * @param repository
+        * @param ticketId
+        * @return true if the ticket exists
+        */
+       public abstract boolean hasTicket(RepositoryModel repository, long ticketId);
+
+       /**
+        * Returns all tickets.  This is not a Lucene search!
+        *
+        * @param repository
+        * @return all tickets
+        */
+       public List<TicketModel> getTickets(RepositoryModel repository) {
+               return getTickets(repository, null);
+       }
+
+       /**
+        * Returns all tickets that satisfy the filter. Retrieving tickets from the
+        * service requires deserializing all journals and building ticket models.
+        * This is an  expensive process and not recommended. Instead, the queryFor
+        * method should be used which executes against the Lucene index.
+        *
+        * @param repository
+        * @param filter
+        *            optional issue filter to only return matching results
+        * @return a list of tickets
+        */
+       public abstract List<TicketModel> getTickets(RepositoryModel repository, TicketFilter filter);
+
+       /**
+        * Retrieves the ticket.
+        *
+        * @param repository
+        * @param ticketId
+        * @return a ticket, if it exists, otherwise null
+        */
+       public final TicketModel getTicket(RepositoryModel repository, long ticketId) {
+               TicketKey key = new TicketKey(repository, ticketId);
+               TicketModel ticket = ticketsCache.getIfPresent(key);
+
+               if (ticket == null) {
+                       // load & cache ticket
+                       ticket = getTicketImpl(repository, ticketId);
+                       if (ticket.hasPatchsets()) {
+                               Repository r = repositoryManager.getRepository(repository.name);
+                               try {
+                                       Patchset patchset = ticket.getCurrentPatchset();
+                                       DiffStat diffStat = DiffUtils.getDiffStat(r, patchset.base, patchset.tip);
+                                       // diffstat could be null if we have ticket data without the
+                                       // commit objects.  e.g. ticket replication without repo
+                                       // mirroring
+                                       if (diffStat != null) {
+                                               ticket.insertions = diffStat.getInsertions();
+                                               ticket.deletions = diffStat.getDeletions();
+                                       }
+                               } finally {
+                                       r.close();
+                               }
+                       }
+                       if (ticket != null) {
+                               ticketsCache.put(key, ticket);
+                       }
+               }
+               return ticket;
+       }
+
+       /**
+        * Retrieves the ticket.
+        *
+        * @param repository
+        * @param ticketId
+        * @return a ticket, if it exists, otherwise null
+        */
+       protected abstract TicketModel getTicketImpl(RepositoryModel repository, long ticketId);
+
+       /**
+        * Get the ticket url
+        *
+        * @param ticket
+        * @return the ticket url
+        */
+       public String getTicketUrl(TicketModel ticket) {
+               final String canonicalUrl = settings.getString(Keys.web.canonicalUrl, "https://localhost:8443");
+               final String hrefPattern = "{0}/tickets?r={1}&h={2,number,0}";
+               return MessageFormat.format(hrefPattern, canonicalUrl, ticket.repository, ticket.number);
+       }
+
+       /**
+        * Get the compare url
+        *
+        * @param base
+        * @param tip
+        * @return the compare url
+        */
+       public String getCompareUrl(TicketModel ticket, String base, String tip) {
+               final String canonicalUrl = settings.getString(Keys.web.canonicalUrl, "https://localhost:8443");
+               final String hrefPattern = "{0}/compare?r={1}&h={2}..{3}";
+               return MessageFormat.format(hrefPattern, canonicalUrl, ticket.repository, base, tip);
+       }
+
+       /**
+        * Returns true if attachments are supported.
+        *
+        * @return true if attachments are supported
+        */
+       public abstract boolean supportsAttachments();
+
+       /**
+        * Retrieves the specified attachment from a ticket.
+        *
+        * @param repository
+        * @param ticketId
+        * @param filename
+        * @return an attachment, if found, null otherwise
+        */
+       public abstract Attachment getAttachment(RepositoryModel repository, long ticketId, String filename);
+
+       /**
+        * Creates a ticket.  Your change must include a repository, author & title,
+        * at a minimum. If your change does not have those minimum requirements a
+        * RuntimeException will be thrown.
+        *
+        * @param repository
+        * @param change
+        * @return true if successful
+        */
+       public TicketModel createTicket(RepositoryModel repository, Change change) {
+               return createTicket(repository, 0L, change);
+       }
+
+       /**
+        * Creates a ticket.  Your change must include a repository, author & title,
+        * at a minimum. If your change does not have those minimum requirements a
+        * RuntimeException will be thrown.
+        *
+        * @param repository
+        * @param ticketId (if <=0 the ticket id will be assigned)
+        * @param change
+        * @return true if successful
+        */
+       public TicketModel createTicket(RepositoryModel repository, long ticketId, Change change) {
+
+               if (repository == null) {
+                       throw new RuntimeException("Must specify a repository!");
+               }
+               if (StringUtils.isEmpty(change.author)) {
+                       throw new RuntimeException("Must specify a change author!");
+               }
+               if (!change.hasField(Field.title)) {
+                       throw new RuntimeException("Must specify a title!");
+               }
+
+               change.watch(change.author);
+
+               if (ticketId <= 0L) {
+                       ticketId = assignNewId(repository);
+               }
+
+               change.setField(Field.status, Status.New);
+
+               boolean success = commitChangeImpl(repository, ticketId, change);
+               if (success) {
+                       TicketModel ticket = getTicket(repository, ticketId);
+                       indexer.index(ticket);
+                       return ticket;
+               }
+               return null;
+       }
+
+       /**
+        * Updates a ticket.
+        *
+        * @param repository
+        * @param ticketId
+        * @param change
+        * @return the ticket model if successful
+        */
+       public final TicketModel updateTicket(RepositoryModel repository, long ticketId, Change change) {
+               if (change == null) {
+                       throw new RuntimeException("change can not be null!");
+               }
+
+               if (StringUtils.isEmpty(change.author)) {
+                       throw new RuntimeException("must specify a change author!");
+               }
+
+               TicketKey key = new TicketKey(repository, ticketId);
+               ticketsCache.invalidate(key);
+
+               boolean success = commitChangeImpl(repository, ticketId, change);
+               if (success) {
+                       TicketModel ticket = getTicket(repository, ticketId);
+                       ticketsCache.put(key, ticket);
+                       indexer.index(ticket);
+                       return ticket;
+               }
+               return null;
+       }
+
+       /**
+        * Deletes all tickets in every repository.
+        *
+        * @return true if successful
+        */
+       public boolean deleteAll() {
+               List<String> repositories = repositoryManager.getRepositoryList();
+               BitSet bitset = new BitSet(repositories.size());
+               for (int i = 0; i < repositories.size(); i++) {
+                       String name = repositories.get(i);
+                       RepositoryModel repository = repositoryManager.getRepositoryModel(name);
+                       boolean success = deleteAll(repository);
+                       bitset.set(i, success);
+               }
+               boolean success = bitset.cardinality() == repositories.size();
+               if (success) {
+                       indexer.deleteAll();
+                       resetCaches();
+               }
+               return success;
+       }
+
+       /**
+        * Deletes all tickets in the specified repository.
+        * @param repository
+        * @return true if succesful
+        */
+       public boolean deleteAll(RepositoryModel repository) {
+               boolean success = deleteAllImpl(repository);
+               if (success) {
+                       resetCaches(repository);
+                       indexer.deleteAll(repository);
+               }
+               return success;
+       }
+
+       protected abstract boolean deleteAllImpl(RepositoryModel repository);
+
+       /**
+        * Handles repository renames.
+        *
+        * @param oldRepositoryName
+        * @param newRepositoryName
+        * @return true if successful
+        */
+       public boolean rename(RepositoryModel oldRepository, RepositoryModel newRepository) {
+               if (renameImpl(oldRepository, newRepository)) {
+                       resetCaches(oldRepository);
+                       indexer.deleteAll(oldRepository);
+                       reindex(newRepository);
+                       return true;
+               }
+               return false;
+       }
+
+       protected abstract boolean renameImpl(RepositoryModel oldRepository, RepositoryModel newRepository);
+
+       /**
+        * Deletes a ticket.
+        *
+        * @param repository
+        * @param ticketId
+        * @param deletedBy
+        * @return true if successful
+        */
+       public boolean deleteTicket(RepositoryModel repository, long ticketId, String deletedBy) {
+               TicketModel ticket = getTicket(repository, ticketId);
+               boolean success = deleteTicketImpl(repository, ticket, deletedBy);
+               if (success) {
+                       ticketsCache.invalidate(new TicketKey(repository, ticketId));
+                       indexer.delete(ticket);
+                       return true;
+               }
+               return false;
+       }
+
+       /**
+        * Deletes a ticket.
+        *
+        * @param repository
+        * @param ticket
+        * @param deletedBy
+        * @return true if successful
+        */
+       protected abstract boolean deleteTicketImpl(RepositoryModel repository, TicketModel ticket, String deletedBy);
+
+
+       /**
+        * Updates the text of an ticket comment.
+        *
+        * @param ticket
+        * @param commentId
+        *            the id of the comment to revise
+        * @param updatedBy
+        *            the author of the updated comment
+        * @param comment
+        *            the revised comment
+        * @return the revised ticket if the change was successful
+        */
+       public final TicketModel updateComment(TicketModel ticket, String commentId,
+                       String updatedBy, String comment) {
+               Change revision = new Change(updatedBy);
+               revision.comment(comment);
+               revision.comment.id = commentId;
+               RepositoryModel repository = repositoryManager.getRepositoryModel(ticket.repository);
+               TicketModel revisedTicket = updateTicket(repository, ticket.number, revision);
+               return revisedTicket;
+       }
+
+       /**
+        * Deletes a comment from a ticket.
+        *
+        * @param ticket
+        * @param commentId
+        *            the id of the comment to delete
+        * @param deletedBy
+        *                      the user deleting the comment
+        * @return the revised ticket if the deletion was successful
+        */
+       public final TicketModel deleteComment(TicketModel ticket, String commentId, String deletedBy) {
+               Change deletion = new Change(deletedBy);
+               deletion.comment("");
+               deletion.comment.id = commentId;
+               deletion.comment.deleted = true;
+               RepositoryModel repository = repositoryManager.getRepositoryModel(ticket.repository);
+               TicketModel revisedTicket = updateTicket(repository, ticket.number, deletion);
+               return revisedTicket;
+       }
+
+       /**
+        * Commit a ticket change to the repository.
+        *
+        * @param repository
+        * @param ticketId
+        * @param change
+        * @return true, if the change was committed
+        */
+       protected abstract boolean commitChangeImpl(RepositoryModel repository, long ticketId, Change change);
+
+
+       /**
+        * Searches for the specified text.  This will use the indexer, if available,
+        * or will fall back to brute-force retrieval of all tickets and string
+        * matching.
+        *
+        * @param repository
+        * @param text
+        * @param page
+        * @param pageSize
+        * @return a list of matching tickets
+        */
+       public List<QueryResult> searchFor(RepositoryModel repository, String text, int page, int pageSize) {
+               return indexer.searchFor(repository, text, page, pageSize);
+       }
+
+       /**
+        * Queries the index for the matching tickets.
+        *
+        * @param query
+        * @param page
+        * @param pageSize
+        * @param sortBy
+        * @param descending
+        * @return a list of matching tickets or an empty list
+        */
+       public List<QueryResult> queryFor(String query, int page, int pageSize, String sortBy, boolean descending) {
+               return indexer.queryFor(query, page, pageSize, sortBy, descending);
+       }
+
+       /**
+        * Destroys an existing index and reindexes all tickets.
+        * This operation may be expensive and time-consuming.
+        */
+       public void reindex() {
+               long start = System.nanoTime();
+               indexer.deleteAll();
+               for (String name : repositoryManager.getRepositoryList()) {
+                       RepositoryModel repository = repositoryManager.getRepositoryModel(name);
+                       try {
+                       List<TicketModel> tickets = getTickets(repository);
+                       if (!tickets.isEmpty()) {
+                               log.info("reindexing {} tickets from {} ...", tickets.size(), repository);
+                               indexer.index(tickets);
+                               System.gc();
+                       }
+                       } catch (Exception e) {
+                               log.error("failed to reindex {}", repository.name);
+                               log.error(null, e);
+                       }
+               }
+               long end = System.nanoTime();
+               long secs = TimeUnit.NANOSECONDS.toMillis(end - start);
+               log.info("reindexing completed in {} msecs.", secs);
+       }
+
+       /**
+        * Destroys any existing index and reindexes all tickets.
+        * This operation may be expensive and time-consuming.
+        */
+       public void reindex(RepositoryModel repository) {
+               long start = System.nanoTime();
+               List<TicketModel> tickets = getTickets(repository);
+               indexer.index(tickets);
+               log.info("reindexing {} tickets from {} ...", tickets.size(), repository);
+               long end = System.nanoTime();
+               long secs = TimeUnit.NANOSECONDS.toMillis(end - start);
+               log.info("reindexing completed in {} msecs.", secs);
+       }
+
+       /**
+        * Synchronously executes the runnable. This is used for special processing
+        * of ticket updates, namely merging from the web ui.
+        *
+        * @param runnable
+        */
+       public synchronized void exec(Runnable runnable) {
+               runnable.run();
+       }
+}
diff --git a/src/main/java/com/gitblit/tickets/NullTicketService.java b/src/main/java/com/gitblit/tickets/NullTicketService.java
new file mode 100644 (file)
index 0000000..cc89302
--- /dev/null
@@ -0,0 +1,129 @@
+/*
+ * Copyright 2014 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.tickets;
+
+import java.util.Collections;
+import java.util.List;
+
+import javax.inject.Inject;
+
+import com.gitblit.manager.INotificationManager;
+import com.gitblit.manager.IRepositoryManager;
+import com.gitblit.manager.IRuntimeManager;
+import com.gitblit.manager.IUserManager;
+import com.gitblit.models.RepositoryModel;
+import com.gitblit.models.TicketModel;
+import com.gitblit.models.TicketModel.Attachment;
+import com.gitblit.models.TicketModel.Change;
+
+/**
+ * Implementation of a ticket service that rejects everything.
+ *
+ * @author James Moger
+ *
+ */
+public class NullTicketService extends ITicketService {
+
+       @Inject
+       public NullTicketService(
+                       IRuntimeManager runtimeManager,
+                       INotificationManager notificationManager,
+                       IUserManager userManager,
+                       IRepositoryManager repositoryManager) {
+
+               super(runtimeManager,
+                               notificationManager,
+                               userManager,
+                               repositoryManager);
+       }
+
+       @Override
+       public boolean isReady() {
+               return false;
+       }
+
+       @Override
+       public NullTicketService start() {
+               return this;
+       }
+
+       @Override
+       protected void resetCachesImpl() {
+       }
+
+       @Override
+       protected void resetCachesImpl(RepositoryModel repository) {
+       }
+
+       @Override
+       protected void close() {
+       }
+
+       @Override
+       public boolean hasTicket(RepositoryModel repository, long ticketId) {
+               return false;
+       }
+
+       @Override
+       public synchronized long assignNewId(RepositoryModel repository) {
+               return 0L;
+       }
+
+       @Override
+       public List<TicketModel> getTickets(RepositoryModel repository, TicketFilter filter) {
+               return Collections.emptyList();
+       }
+
+       @Override
+       protected TicketModel getTicketImpl(RepositoryModel repository, long ticketId) {
+               return null;
+       }
+
+       @Override
+       public boolean supportsAttachments() {
+               return false;
+       }
+
+       @Override
+       public Attachment getAttachment(RepositoryModel repository, long ticketId, String filename) {
+               return null;
+       }
+
+       @Override
+       protected synchronized boolean deleteTicketImpl(RepositoryModel repository, TicketModel ticket, String deletedBy) {
+               return false;
+       }
+
+       @Override
+       protected synchronized boolean commitChangeImpl(RepositoryModel repository, long ticketId, Change change) {
+               return false;
+       }
+
+       @Override
+       protected boolean deleteAllImpl(RepositoryModel repository) {
+               return false;
+       }
+
+       @Override
+       protected boolean renameImpl(RepositoryModel oldRepository, RepositoryModel newRepository) {
+               return false;
+       }
+
+       @Override
+       public String toString() {
+               return getClass().getSimpleName();
+       }
+}
diff --git a/src/main/java/com/gitblit/tickets/QueryBuilder.java b/src/main/java/com/gitblit/tickets/QueryBuilder.java
new file mode 100644 (file)
index 0000000..17aeb98
--- /dev/null
@@ -0,0 +1,222 @@
+/*
+ * Copyright 2013 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.tickets;
+
+import com.gitblit.utils.StringUtils;
+
+/**
+ * A Lucene query builder.
+ *
+ * @author James Moger
+ *
+ */
+public class QueryBuilder {
+
+       private final QueryBuilder parent;
+       private String q;
+       private transient StringBuilder sb;
+       private int opCount;
+
+       public static QueryBuilder q(String kernel) {
+               return new QueryBuilder(kernel);
+       }
+
+       private QueryBuilder(QueryBuilder parent) {
+               this.sb = new StringBuilder();
+               this.parent = parent;
+       }
+
+       public QueryBuilder() {
+               this("");
+       }
+
+       public QueryBuilder(String query) {
+               this.sb = new StringBuilder(query == null ? "" : query);
+               this.parent = null;
+       }
+
+       public boolean containsField(String field) {
+               return sb.toString().contains(field + ":");
+       }
+
+       /**
+        * Creates a new AND subquery.  Make sure to call endSubquery to
+        * get return *this* query.
+        *
+        * e.g. field:something AND (subquery)
+        *
+        * @return a subquery
+        */
+       public QueryBuilder andSubquery() {
+               sb.append(" AND (");
+               return new QueryBuilder(this);
+       }
+
+       /**
+        * Creates a new OR subquery.  Make sure to call endSubquery to
+        * get return *this* query.
+        *
+        * e.g. field:something OR (subquery)
+        *
+        * @return a subquery
+        */
+       public QueryBuilder orSubquery() {
+               sb.append(" OR (");
+               return new QueryBuilder(this);
+       }
+
+       /**
+        * Ends a subquery and returns the parent query.
+        *
+        * @return the parent query
+        */
+       public QueryBuilder endSubquery() {
+               this.q = sb.toString().trim();
+               if (q.length() > 0) {
+                       parent.sb.append(q).append(')');
+               }
+               return parent;
+       }
+
+       /**
+        * Append an OR condition.
+        *
+        * @param condition
+        * @return
+        */
+       public QueryBuilder or(String condition) {
+               return op(condition, " OR ");
+       }
+
+       /**
+        * Append an AND condition.
+        *
+        * @param condition
+        * @return
+        */
+       public QueryBuilder and(String condition) {
+               return op(condition, " AND ");
+       }
+
+       /**
+        * Append an AND NOT condition.
+        *
+        * @param condition
+        * @return
+        */
+       public QueryBuilder andNot(String condition) {
+               return op(condition, " AND NOT ");
+       }
+
+       /**
+        * Nest this query as a subquery.
+        *
+        * e.g. field:something AND field2:something else
+        * ==>  (field:something AND field2:something else)
+        *
+        * @return this query nested as a subquery
+        */
+       public QueryBuilder toSubquery() {
+               if (opCount > 1) {
+                       sb.insert(0, '(').append(')');
+               }
+               return this;
+       }
+
+       /**
+        * Nest this query as an AND subquery of the condition
+        *
+        * @param condition
+        * @return the query nested as an AND subquery of the specified condition
+        */
+       public QueryBuilder subqueryOf(String condition) {
+               if (!StringUtils.isEmpty(condition)) {
+                       toSubquery().and(condition);
+               }
+               return this;
+       }
+
+       /**
+        * Removes a condition from the query.
+        *
+        * @param condition
+        * @return the query
+        */
+       public QueryBuilder remove(String condition) {
+               int start = sb.indexOf(condition);
+               if (start == 0) {
+                       // strip first condition
+                       sb.replace(0, condition.length(), "");
+               } else if (start > 1) {
+                       // locate condition in query
+                       int space1 = sb.lastIndexOf(" ", start - 1);
+                       int space0 = sb.lastIndexOf(" ", space1 - 1);
+                       if (space0 > -1 && space1 > -1) {
+                               String conjunction = sb.substring(space0,  space1).trim();
+                               if ("OR".equals(conjunction) || "AND".equals(conjunction)) {
+                                       // remove the conjunction
+                                       sb.replace(space0, start + condition.length(), "");
+                               } else {
+                                       // unknown conjunction
+                                       sb.replace(start, start + condition.length(), "");
+                               }
+                       } else {
+                               sb.replace(start, start + condition.length(), "");
+                       }
+               }
+               return this;
+       }
+
+       /**
+        * Generate the return the Lucene query.
+        *
+        * @return the generated query
+        */
+       public String build() {
+               if (parent != null) {
+                       throw new IllegalAccessError("You can not build a subquery! endSubquery() instead!");
+               }
+               this.q = sb.toString().trim();
+
+               // cleanup paranthesis
+               while (q.contains("()")) {
+                       q = q.replace("()", "");
+               }
+               if (q.length() > 0) {
+                       if (q.charAt(0) == '(' && q.charAt(q.length() - 1) == ')') {
+                               // query is wrapped by unnecessary paranthesis
+                               q = q.substring(1, q.length() - 1);
+                       }
+               }
+               return q;
+       }
+
+       private QueryBuilder op(String condition, String op) {
+               opCount++;
+               if (!StringUtils.isEmpty(condition)) {
+                       if (sb.length() != 0) {
+                               sb.append(op);
+                       }
+                       sb.append(condition);
+               }
+               return this;
+       }
+
+       @Override
+       public String toString() {
+               return sb.toString().trim();
+       }
+}
\ No newline at end of file
diff --git a/src/main/java/com/gitblit/tickets/QueryResult.java b/src/main/java/com/gitblit/tickets/QueryResult.java
new file mode 100644 (file)
index 0000000..9f5d3a5
--- /dev/null
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2013 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.tickets;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+
+import com.gitblit.models.TicketModel.Patchset;
+import com.gitblit.models.TicketModel.Status;
+import com.gitblit.models.TicketModel.Type;
+import com.gitblit.utils.StringUtils;
+
+/**
+ * Represents the results of a query to the ticket index.
+ *
+ * @author James Moger
+ *
+ */
+public class QueryResult implements Serializable {
+
+       private static final long serialVersionUID = 1L;
+
+       public String project;
+       public String repository;
+       public long number;
+       public String createdBy;
+       public Date createdAt;
+       public String updatedBy;
+       public Date updatedAt;
+       public String dependsOn;
+       public String title;
+       public String body;
+       public Status status;
+       public String responsible;
+       public String milestone;
+       public String topic;
+       public Type type;
+       public String mergeSha;
+       public String mergeTo;
+       public List<String> labels;
+       public List<String> attachments;
+       public List<String> participants;
+       public List<String> watchedby;
+       public List<String> mentions;
+       public Patchset patchset;
+       public int commentsCount;
+       public int votesCount;
+       public int approvalsCount;
+
+       public int docId;
+       public int totalResults;
+
+       public Date getDate() {
+               return updatedAt == null ? createdAt : updatedAt;
+       }
+
+       public boolean isProposal() {
+               return type != null && Type.Proposal == type;
+       }
+
+       public boolean isMerged() {
+               return Status.Merged == status && !StringUtils.isEmpty(mergeSha);
+       }
+
+       public boolean isWatching(String username) {
+               return watchedby != null && watchedby.contains(username);
+       }
+
+       public List<String> getLabels() {
+               List<String> list = new ArrayList<String>();
+               if (labels != null) {
+                       list.addAll(labels);
+               }
+               if (topic != null) {
+                       list.add(topic);
+               }
+               Collections.sort(list);
+               return list;
+       }
+
+       @Override
+       public boolean equals(Object o) {
+               if (o instanceof QueryResult) {
+                       return hashCode() == o.hashCode();
+               }
+               return false;
+       }
+
+       @Override
+       public int hashCode() {
+               return (repository + number).hashCode();
+       }
+
+       @Override
+       public String toString() {
+               return repository + "-" + number;
+       }
+}
diff --git a/src/main/java/com/gitblit/tickets/RedisTicketService.java b/src/main/java/com/gitblit/tickets/RedisTicketService.java
new file mode 100644 (file)
index 0000000..5653f69
--- /dev/null
@@ -0,0 +1,534 @@
+/*
+ * Copyright 2013 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.tickets;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+import javax.inject.Inject;
+
+import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
+
+import redis.clients.jedis.Client;
+import redis.clients.jedis.Jedis;
+import redis.clients.jedis.JedisPool;
+import redis.clients.jedis.Protocol;
+import redis.clients.jedis.Transaction;
+import redis.clients.jedis.exceptions.JedisException;
+
+import com.gitblit.Keys;
+import com.gitblit.manager.INotificationManager;
+import com.gitblit.manager.IRepositoryManager;
+import com.gitblit.manager.IRuntimeManager;
+import com.gitblit.manager.IUserManager;
+import com.gitblit.models.RepositoryModel;
+import com.gitblit.models.TicketModel;
+import com.gitblit.models.TicketModel.Attachment;
+import com.gitblit.models.TicketModel.Change;
+import com.gitblit.utils.ArrayUtils;
+import com.gitblit.utils.StringUtils;
+
+/**
+ * Implementation of a ticket service based on a Redis key-value store.  All
+ * tickets are persisted in the Redis store so it must be configured for
+ * durability otherwise tickets are lost on a flush or restart.  Tickets are
+ * indexed with Lucene and all queries are executed against the Lucene index.
+ *
+ * @author James Moger
+ *
+ */
+public class RedisTicketService extends ITicketService {
+
+       private final JedisPool pool;
+
+       private enum KeyType {
+               journal, ticket, counter
+       }
+
+       @Inject
+       public RedisTicketService(
+                       IRuntimeManager runtimeManager,
+                       INotificationManager notificationManager,
+                       IUserManager userManager,
+                       IRepositoryManager repositoryManager) {
+
+               super(runtimeManager,
+                               notificationManager,
+                               userManager,
+                               repositoryManager);
+
+               String redisUrl = settings.getString(Keys.tickets.redis.url, "");
+               this.pool = createPool(redisUrl);
+       }
+
+       @Override
+       public RedisTicketService start() {
+               return this;
+       }
+
+       @Override
+       protected void resetCachesImpl() {
+       }
+
+       @Override
+       protected void resetCachesImpl(RepositoryModel repository) {
+       }
+
+       @Override
+       protected void close() {
+               pool.destroy();
+       }
+
+       @Override
+       public boolean isReady() {
+               return pool != null;
+       }
+
+       /**
+        * Constructs a key for use with a key-value data store.
+        *
+        * @param key
+        * @param repository
+        * @param id
+        * @return a key
+        */
+       private String key(RepositoryModel repository, KeyType key, String id) {
+               StringBuilder sb = new StringBuilder();
+               sb.append(repository.name).append(':');
+               sb.append(key.name());
+               if (!StringUtils.isEmpty(id)) {
+                       sb.append(':');
+                       sb.append(id);
+               }
+               return sb.toString();
+       }
+
+       /**
+        * Constructs a key for use with a key-value data store.
+        *
+        * @param key
+        * @param repository
+        * @param id
+        * @return a key
+        */
+       private String key(RepositoryModel repository, KeyType key, long id) {
+               return key(repository, key, "" + id);
+       }
+
+       private boolean isNull(String value) {
+               return value == null || "nil".equals(value);
+       }
+
+       private String getUrl() {
+               Jedis jedis = pool.getResource();
+               try {
+                       if (jedis != null) {
+                               Client client = jedis.getClient();
+                               return client.getHost() + ":" + client.getPort() + "/" + client.getDB();
+                       }
+               } catch (JedisException e) {
+                       pool.returnBrokenResource(jedis);
+                       jedis = null;
+               } finally {
+                       if (jedis != null) {
+                               pool.returnResource(jedis);
+                       }
+               }
+               return null;
+       }
+
+       /**
+        * Ensures that we have a ticket for this ticket id.
+        *
+        * @param repository
+        * @param ticketId
+        * @return true if the ticket exists
+        */
+       @Override
+       public boolean hasTicket(RepositoryModel repository, long ticketId) {
+               if (ticketId <= 0L) {
+                       return false;
+               }
+               Jedis jedis = pool.getResource();
+               if (jedis == null) {
+                       return false;
+               }
+               try {
+                       Boolean exists = jedis.exists(key(repository, KeyType.journal, ticketId));
+                       return exists != null && !exists;
+               } catch (JedisException e) {
+                       log.error("failed to check hasTicket from Redis @ " + getUrl(), e);
+                       pool.returnBrokenResource(jedis);
+                       jedis = null;
+               } finally {
+                       if (jedis != null) {
+                               pool.returnResource(jedis);
+                       }
+               }
+               return false;
+       }
+
+       /**
+        * Assigns a new ticket id.
+        *
+        * @param repository
+        * @return a new long ticket id
+        */
+       @Override
+       public synchronized long assignNewId(RepositoryModel repository) {
+               Jedis jedis = pool.getResource();
+               try {
+                       String key = key(repository, KeyType.counter, null);
+                       String val = jedis.get(key);
+                       if (isNull(val)) {
+                               jedis.set(key, "0");
+                       }
+                       long ticketNumber = jedis.incr(key);
+                       return ticketNumber;
+               } catch (JedisException e) {
+                       log.error("failed to assign new ticket id in Redis @ " + getUrl(), e);
+                       pool.returnBrokenResource(jedis);
+                       jedis = null;
+               } finally {
+                       if (jedis != null) {
+                               pool.returnResource(jedis);
+                       }
+               }
+               return 0L;
+       }
+
+       /**
+        * Returns all the tickets in the repository. Querying tickets from the
+        * repository requires deserializing all tickets. This is an  expensive
+        * process and not recommended. Tickets should be indexed by Lucene and
+        * queries should be executed against that index.
+        *
+        * @param repository
+        * @param filter
+        *            optional filter to only return matching results
+        * @return a list of tickets
+        */
+       @Override
+       public List<TicketModel> getTickets(RepositoryModel repository, TicketFilter filter) {
+               Jedis jedis = pool.getResource();
+               List<TicketModel> list = new ArrayList<TicketModel>();
+               if (jedis == null) {
+                       return list;
+               }
+               try {
+                       // Deserialize each journal, build the ticket, and optionally filter
+                       Set<String> keys = jedis.keys(key(repository, KeyType.journal, "*"));
+                       for (String key : keys) {
+                               // {repo}:journal:{id}
+                               String id = key.split(":")[2];
+                               long ticketId = Long.parseLong(id);
+                               List<Change> changes = getJournal(jedis, repository, ticketId);
+                               if (ArrayUtils.isEmpty(changes)) {
+                                       log.warn("Empty journal for {}:{}", repository, ticketId);
+                                       continue;
+                               }
+                               TicketModel ticket = TicketModel.buildTicket(changes);
+                               ticket.project = repository.projectPath;
+                               ticket.repository = repository.name;
+                               ticket.number = ticketId;
+
+                               // add the ticket, conditionally, to the list
+                               if (filter == null) {
+                                       list.add(ticket);
+                               } else {
+                                       if (filter.accept(ticket)) {
+                                               list.add(ticket);
+                                       }
+                               }
+                       }
+
+                       // sort the tickets by creation
+                       Collections.sort(list);
+               } catch (JedisException e) {
+                       log.error("failed to retrieve tickets from Redis @ " + getUrl(), e);
+                       pool.returnBrokenResource(jedis);
+                       jedis = null;
+               } finally {
+                       if (jedis != null) {
+                               pool.returnResource(jedis);
+                       }
+               }
+               return list;
+       }
+
+       /**
+        * Retrieves the ticket from the repository by first looking-up the changeId
+        * associated with the ticketId.
+        *
+        * @param repository
+        * @param ticketId
+        * @return a ticket, if it exists, otherwise null
+        */
+       @Override
+       protected TicketModel getTicketImpl(RepositoryModel repository, long ticketId) {
+               Jedis jedis = pool.getResource();
+               if (jedis == null) {
+                       return null;
+               }
+
+               try {
+                       List<Change> changes = getJournal(jedis, repository, ticketId);
+                       if (ArrayUtils.isEmpty(changes)) {
+                               log.warn("Empty journal for {}:{}", repository, ticketId);
+                               return null;
+                       }
+                       TicketModel ticket = TicketModel.buildTicket(changes);
+                       ticket.project = repository.projectPath;
+                       ticket.repository = repository.name;
+                       ticket.number = ticketId;
+                       log.debug("rebuilt ticket {} from Redis @ {}", ticketId, getUrl());
+                       return ticket;
+               } catch (JedisException e) {
+                       log.error("failed to retrieve ticket from Redis @ " + getUrl(), e);
+                       pool.returnBrokenResource(jedis);
+                       jedis = null;
+               } finally {
+                       if (jedis != null) {
+                               pool.returnResource(jedis);
+                       }
+               }
+               return null;
+       }
+
+       /**
+        * Returns the journal for the specified ticket.
+        *
+        * @param repository
+        * @param ticketId
+        * @return a list of changes
+        */
+       private List<Change> getJournal(Jedis jedis, RepositoryModel repository, long ticketId) throws JedisException {
+               if (ticketId <= 0L) {
+                       return new ArrayList<Change>();
+               }
+               List<String> entries = jedis.lrange(key(repository, KeyType.journal, ticketId), 0, -1);
+               if (entries.size() > 0) {
+                       // build a json array from the individual entries
+                       StringBuilder sb = new StringBuilder();
+                       sb.append("[");
+                       for (String entry : entries) {
+                               sb.append(entry).append(',');
+                       }
+                       sb.setLength(sb.length() - 1);
+                       sb.append(']');
+                       String journal = sb.toString();
+
+                       return TicketSerializer.deserializeJournal(journal);
+               }
+               return new ArrayList<Change>();
+       }
+
+       @Override
+       public boolean supportsAttachments() {
+               return false;
+       }
+
+       /**
+        * Retrieves the specified attachment from a ticket.
+        *
+        * @param repository
+        * @param ticketId
+        * @param filename
+        * @return an attachment, if found, null otherwise
+        */
+       @Override
+       public Attachment getAttachment(RepositoryModel repository, long ticketId, String filename) {
+               return null;
+       }
+
+       /**
+        * Deletes a ticket.
+        *
+        * @param ticket
+        * @return true if successful
+        */
+       @Override
+       protected boolean deleteTicketImpl(RepositoryModel repository, TicketModel ticket, String deletedBy) {
+               boolean success = false;
+               if (ticket == null) {
+                       throw new RuntimeException("must specify a ticket!");
+               }
+
+               Jedis jedis = pool.getResource();
+               if (jedis == null) {
+                       return false;
+               }
+
+               try {
+                       // atomically remove ticket
+                       Transaction t = jedis.multi();
+                       t.del(key(repository, KeyType.ticket, ticket.number));
+                       t.del(key(repository, KeyType.journal, ticket.number));
+                       t.exec();
+
+                       success = true;
+                       log.debug("deleted ticket {} from Redis @ {}", "" + ticket.number, getUrl());
+               } catch (JedisException e) {
+                       log.error("failed to delete ticket from Redis @ " + getUrl(), e);
+                       pool.returnBrokenResource(jedis);
+                       jedis = null;
+               } finally {
+                       if (jedis != null) {
+                               pool.returnResource(jedis);
+                       }
+               }
+
+               return success;
+       }
+
+       /**
+        * Commit a ticket change to the repository.
+        *
+        * @param repository
+        * @param ticketId
+        * @param change
+        * @return true, if the change was committed
+        */
+       @Override
+       protected boolean commitChangeImpl(RepositoryModel repository, long ticketId, Change change) {
+               Jedis jedis = pool.getResource();
+               if (jedis == null) {
+                       return false;
+               }
+               try {
+                       List<Change> changes = getJournal(jedis, repository, ticketId);
+                       changes.add(change);
+                       // build a new effective ticket from the changes
+                       TicketModel ticket = TicketModel.buildTicket(changes);
+
+                       String object = TicketSerializer.serialize(ticket);
+                       String journal = TicketSerializer.serialize(change);
+
+                       // atomically store ticket
+                       Transaction t = jedis.multi();
+                       t.set(key(repository, KeyType.ticket, ticketId), object);
+                       t.rpush(key(repository, KeyType.journal, ticketId), journal);
+                       t.exec();
+
+                       log.debug("updated ticket {} in Redis @ {}", "" + ticketId, getUrl());
+                       return true;
+               } catch (JedisException e) {
+                       log.error("failed to update ticket cache in Redis @ " + getUrl(), e);
+                       pool.returnBrokenResource(jedis);
+                       jedis = null;
+               } finally {
+                       if (jedis != null) {
+                               pool.returnResource(jedis);
+                       }
+               }
+               return false;
+       }
+
+       /**
+        *  Deletes all Tickets for the rpeository from the Redis key-value store.
+        *
+        */
+       @Override
+       protected boolean deleteAllImpl(RepositoryModel repository) {
+               Jedis jedis = pool.getResource();
+               if (jedis == null) {
+                       return false;
+               }
+
+               boolean success = false;
+               try {
+                       Set<String> keys = jedis.keys(repository.name + ":*");
+                       if (keys.size() > 0) {
+                               Transaction t = jedis.multi();
+                               t.del(keys.toArray(new String[keys.size()]));
+                               t.exec();
+                       }
+                       success = true;
+               } catch (JedisException e) {
+                       log.error("failed to delete all tickets in Redis @ " + getUrl(), e);
+                       pool.returnBrokenResource(jedis);
+                       jedis = null;
+               } finally {
+                       if (jedis != null) {
+                               pool.returnResource(jedis);
+                       }
+               }
+               return success;
+       }
+
+       @Override
+       protected boolean renameImpl(RepositoryModel oldRepository, RepositoryModel newRepository) {
+               Jedis jedis = pool.getResource();
+               if (jedis == null) {
+                       return false;
+               }
+
+               boolean success = false;
+               try {
+                       Set<String> oldKeys = jedis.keys(oldRepository.name + ":*");
+                       Transaction t = jedis.multi();
+                       for (String oldKey : oldKeys) {
+                               String newKey = newRepository.name + oldKey.substring(oldKey.indexOf(':'));
+                               t.rename(oldKey, newKey);
+                       }
+                       t.exec();
+                       success = true;
+               } catch (JedisException e) {
+                       log.error("failed to rename tickets in Redis @ " + getUrl(), e);
+                       pool.returnBrokenResource(jedis);
+                       jedis = null;
+               } finally {
+                       if (jedis != null) {
+                               pool.returnResource(jedis);
+                       }
+               }
+               return success;
+       }
+
+       private JedisPool createPool(String url) {
+               JedisPool pool = null;
+               if (!StringUtils.isEmpty(url)) {
+                       try {
+                               URI uri = URI.create(url);
+                               if (uri.getScheme() != null && uri.getScheme().equalsIgnoreCase("redis")) {
+                                       int database = Protocol.DEFAULT_DATABASE;
+                                       String password = null;
+                                       if (uri.getUserInfo() != null) {
+                                               password = uri.getUserInfo().split(":", 2)[1];
+                                       }
+                                       if (uri.getPath().indexOf('/') > -1) {
+                                               database = Integer.parseInt(uri.getPath().split("/", 2)[1]);
+                                       }
+                                       pool = new JedisPool(new GenericObjectPoolConfig(), uri.getHost(), uri.getPort(), Protocol.DEFAULT_TIMEOUT, password, database);
+                               } else {
+                                       pool = new JedisPool(url);
+                               }
+                       } catch (JedisException e) {
+                               log.error("failed to create a Redis pool!", e);
+                       }
+               }
+               return pool;
+       }
+
+       @Override
+       public String toString() {
+               String url = getUrl();
+               return getClass().getSimpleName() + " (" + (url == null ? "DISABLED" : url) + ")";
+       }
+}
diff --git a/src/main/java/com/gitblit/tickets/TicketIndexer.java b/src/main/java/com/gitblit/tickets/TicketIndexer.java
new file mode 100644 (file)
index 0000000..3929a00
--- /dev/null
@@ -0,0 +1,657 @@
+/*
+ * Copyright 2014 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.tickets;
+
+import java.io.File;
+import java.io.IOException;
+import java.text.MessageFormat;
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.lucene.analysis.standard.StandardAnalyzer;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.Field.Store;
+import org.apache.lucene.document.IntField;
+import org.apache.lucene.document.LongField;
+import org.apache.lucene.document.TextField;
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.IndexWriterConfig;
+import org.apache.lucene.index.IndexWriterConfig.OpenMode;
+import org.apache.lucene.queryparser.classic.QueryParser;
+import org.apache.lucene.search.BooleanClause.Occur;
+import org.apache.lucene.search.BooleanQuery;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.ScoreDoc;
+import org.apache.lucene.search.Sort;
+import org.apache.lucene.search.SortField;
+import org.apache.lucene.search.SortField.Type;
+import org.apache.lucene.search.TopFieldDocs;
+import org.apache.lucene.search.TopScoreDocCollector;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.FSDirectory;
+import org.apache.lucene.util.Version;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.gitblit.Keys;
+import com.gitblit.manager.IRuntimeManager;
+import com.gitblit.models.RepositoryModel;
+import com.gitblit.models.TicketModel;
+import com.gitblit.models.TicketModel.Attachment;
+import com.gitblit.models.TicketModel.Patchset;
+import com.gitblit.models.TicketModel.Status;
+import com.gitblit.utils.FileUtils;
+import com.gitblit.utils.StringUtils;
+
+/**
+ * Indexes tickets in a Lucene database.
+ *
+ * @author James Moger
+ *
+ */
+public class TicketIndexer {
+
+       /**
+        * Fields in the Lucene index
+        */
+       public static enum Lucene {
+
+               rid(Type.STRING),
+               did(Type.STRING),
+               project(Type.STRING),
+               repository(Type.STRING),
+               number(Type.LONG),
+               title(Type.STRING),
+               body(Type.STRING),
+               topic(Type.STRING),
+               created(Type.LONG),
+               createdby(Type.STRING),
+               updated(Type.LONG),
+               updatedby(Type.STRING),
+               responsible(Type.STRING),
+               milestone(Type.STRING),
+               status(Type.STRING),
+               type(Type.STRING),
+               labels(Type.STRING),
+               participants(Type.STRING),
+               watchedby(Type.STRING),
+               mentions(Type.STRING),
+               attachments(Type.INT),
+               content(Type.STRING),
+               patchset(Type.STRING),
+               comments(Type.INT),
+               mergesha(Type.STRING),
+               mergeto(Type.STRING),
+               patchsets(Type.INT),
+               votes(Type.INT);
+
+               final Type fieldType;
+
+               Lucene(Type fieldType) {
+                       this.fieldType = fieldType;
+               }
+
+               public String colon() {
+                       return name() + ":";
+               }
+
+               public String matches(String value) {
+                       if (StringUtils.isEmpty(value)) {
+                               return "";
+                       }
+                       boolean not = value.charAt(0) == '!';
+                       if (not) {
+                               return "!" + name() + ":" + escape(value.substring(1));
+                       }
+                       return name() + ":" + escape(value);
+               }
+
+               public String doesNotMatch(String value) {
+                       if (StringUtils.isEmpty(value)) {
+                               return "";
+                       }
+                       return "NOT " + name() + ":" + escape(value);
+               }
+
+               public String isNotNull() {
+                       return matches("[* TO *]");
+               }
+
+               public SortField asSortField(boolean descending) {
+                       return new SortField(name(), fieldType, descending);
+               }
+
+               private String escape(String value) {
+                       if (value.charAt(0) != '"') {
+                               if (value.indexOf('/') > -1) {
+                                       return "\"" + value + "\"";
+                               }
+                       }
+                       return value;
+               }
+
+               public static Lucene fromString(String value) {
+                       for (Lucene field : values()) {
+                               if (field.name().equalsIgnoreCase(value)) {
+                                       return field;
+                               }
+                       }
+                       return created;
+               }
+       }
+
+       private final Logger log = LoggerFactory.getLogger(getClass());
+
+       private final Version luceneVersion = Version.LUCENE_46;
+
+       private final File luceneDir;
+
+       private IndexWriter writer;
+
+       private IndexSearcher searcher;
+
+       public TicketIndexer(IRuntimeManager runtimeManager) {
+               this.luceneDir = runtimeManager.getFileOrFolder(Keys.tickets.indexFolder, "${baseFolder}/tickets/lucene");
+       }
+
+       /**
+        * Close all writers and searchers used by the ticket indexer.
+        */
+       public void close() {
+               closeSearcher();
+               closeWriter();
+       }
+
+       /**
+        * Deletes the entire ticket index for all repositories.
+        */
+       public void deleteAll() {
+               close();
+               FileUtils.delete(luceneDir);
+       }
+
+       /**
+        * Deletes all tickets for the the repository from the index.
+        */
+       public boolean deleteAll(RepositoryModel repository) {
+               try {
+                       IndexWriter writer = getWriter();
+                       StandardAnalyzer analyzer = new StandardAnalyzer(luceneVersion);
+                       QueryParser qp = new QueryParser(luceneVersion, Lucene.rid.name(), analyzer);
+                       BooleanQuery query = new BooleanQuery();
+                       query.add(qp.parse(repository.getRID()), Occur.MUST);
+
+                       int numDocsBefore = writer.numDocs();
+                       writer.deleteDocuments(query);
+                       writer.commit();
+                       closeSearcher();
+                       int numDocsAfter = writer.numDocs();
+                       if (numDocsBefore == numDocsAfter) {
+                               log.debug(MessageFormat.format("no records found to delete in {0}", repository));
+                               return false;
+                       } else {
+                               log.debug(MessageFormat.format("deleted {0} records in {1}", numDocsBefore - numDocsAfter, repository));
+                               return true;
+                       }
+               } catch (Exception e) {
+                       log.error("error", e);
+               }
+               return false;
+       }
+
+       /**
+        * Bulk Add/Update tickets in the Lucene index
+        *
+        * @param tickets
+        */
+       public void index(List<TicketModel> tickets) {
+               try {
+                       IndexWriter writer = getWriter();
+                       for (TicketModel ticket : tickets) {
+                               Document doc = ticketToDoc(ticket);
+                               writer.addDocument(doc);
+                       }
+                       writer.commit();
+                       closeSearcher();
+               } catch (Exception e) {
+                       log.error("error", e);
+               }
+       }
+
+       /**
+        * Add/Update a ticket in the Lucene index
+        *
+        * @param ticket
+        */
+       public void index(TicketModel ticket) {
+               try {
+                       IndexWriter writer = getWriter();
+                       delete(ticket.repository, ticket.number, writer);
+                       Document doc = ticketToDoc(ticket);
+                       writer.addDocument(doc);
+                       writer.commit();
+                       closeSearcher();
+               } catch (Exception e) {
+                       log.error("error", e);
+               }
+       }
+
+       /**
+        * Delete a ticket from the Lucene index.
+        *
+        * @param ticket
+        * @throws Exception
+        * @return true, if deleted, false if no record was deleted
+        */
+       public boolean delete(TicketModel ticket) {
+               try {
+                       IndexWriter writer = getWriter();
+                       return delete(ticket.repository, ticket.number, writer);
+               } catch (Exception e) {
+                       log.error("Failed to delete ticket " + ticket.number, e);
+               }
+               return false;
+       }
+
+       /**
+        * Delete a ticket from the Lucene index.
+        *
+        * @param repository
+        * @param ticketId
+        * @throws Exception
+        * @return true, if deleted, false if no record was deleted
+        */
+       private boolean delete(String repository, long ticketId, IndexWriter writer) throws Exception {
+               StandardAnalyzer analyzer = new StandardAnalyzer(luceneVersion);
+               QueryParser qp = new QueryParser(luceneVersion, Lucene.did.name(), analyzer);
+               BooleanQuery query = new BooleanQuery();
+               query.add(qp.parse(StringUtils.getSHA1(repository + ticketId)), Occur.MUST);
+
+               int numDocsBefore = writer.numDocs();
+               writer.deleteDocuments(query);
+               writer.commit();
+               closeSearcher();
+               int numDocsAfter = writer.numDocs();
+               if (numDocsBefore == numDocsAfter) {
+                       log.debug(MessageFormat.format("no records found to delete in {0}", repository));
+                       return false;
+               } else {
+                       log.debug(MessageFormat.format("deleted {0} records in {1}", numDocsBefore - numDocsAfter, repository));
+                       return true;
+               }
+       }
+
+       /**
+        * Returns true if the repository has tickets in the index.
+        *
+        * @param repository
+        * @return true if there are indexed tickets
+        */
+       public boolean hasTickets(RepositoryModel repository) {
+               return !queryFor(Lucene.rid.matches(repository.getRID()), 1, 0, null, true).isEmpty();
+       }
+
+       /**
+        * Search for tickets matching the query.  The returned tickets are
+        * shadows of the real ticket, but suitable for a results list.
+        *
+        * @param repository
+        * @param text
+        * @param page
+        * @param pageSize
+        * @return search results
+        */
+       public List<QueryResult> searchFor(RepositoryModel repository, String text, int page, int pageSize) {
+               if (StringUtils.isEmpty(text)) {
+                       return Collections.emptyList();
+               }
+               Set<QueryResult> results = new LinkedHashSet<QueryResult>();
+               StandardAnalyzer analyzer = new StandardAnalyzer(luceneVersion);
+               try {
+                       // search the title, description and content
+                       BooleanQuery query = new BooleanQuery();
+                       QueryParser qp;
+
+                       qp = new QueryParser(luceneVersion, Lucene.title.name(), analyzer);
+                       qp.setAllowLeadingWildcard(true);
+                       query.add(qp.parse(text), Occur.SHOULD);
+
+                       qp = new QueryParser(luceneVersion, Lucene.body.name(), analyzer);
+                       qp.setAllowLeadingWildcard(true);
+                       query.add(qp.parse(text), Occur.SHOULD);
+
+                       qp = new QueryParser(luceneVersion, Lucene.content.name(), analyzer);
+                       qp.setAllowLeadingWildcard(true);
+                       query.add(qp.parse(text), Occur.SHOULD);
+
+                       IndexSearcher searcher = getSearcher();
+                       Query rewrittenQuery = searcher.rewrite(query);
+
+                       log.debug(rewrittenQuery.toString());
+
+                       TopScoreDocCollector collector = TopScoreDocCollector.create(5000, true);
+                       searcher.search(rewrittenQuery, collector);
+                       int offset = Math.max(0, (page - 1) * pageSize);
+                       ScoreDoc[] hits = collector.topDocs(offset, pageSize).scoreDocs;
+                       for (int i = 0; i < hits.length; i++) {
+                               int docId = hits[i].doc;
+                               Document doc = searcher.doc(docId);
+                               QueryResult result = docToQueryResult(doc);
+                               if (repository != null) {
+                                       if (!result.repository.equalsIgnoreCase(repository.name)) {
+                                               continue;
+                                       }
+                               }
+                               results.add(result);
+                       }
+               } catch (Exception e) {
+                       log.error(MessageFormat.format("Exception while searching for {0}", text), e);
+               }
+               return new ArrayList<QueryResult>(results);
+       }
+
+       /**
+        * Search for tickets matching the query.  The returned tickets are
+        * shadows of the real ticket, but suitable for a results list.
+        *
+        * @param text
+        * @param page
+        * @param pageSize
+        * @param sortBy
+        * @param desc
+        * @return
+        */
+       public List<QueryResult> queryFor(String queryText, int page, int pageSize, String sortBy, boolean desc) {
+               if (StringUtils.isEmpty(queryText)) {
+                       return Collections.emptyList();
+               }
+
+               Set<QueryResult> results = new LinkedHashSet<QueryResult>();
+               StandardAnalyzer analyzer = new StandardAnalyzer(luceneVersion);
+               try {
+                       QueryParser qp = new QueryParser(luceneVersion, Lucene.content.name(), analyzer);
+                       Query query = qp.parse(queryText);
+
+                       IndexSearcher searcher = getSearcher();
+                       Query rewrittenQuery = searcher.rewrite(query);
+
+                       log.debug(rewrittenQuery.toString());
+
+                       Sort sort;
+                       if (sortBy == null) {
+                               sort = new Sort(Lucene.created.asSortField(desc));
+                       } else {
+                               sort = new Sort(Lucene.fromString(sortBy).asSortField(desc));
+                       }
+                       int maxSize = 5000;
+                       TopFieldDocs docs = searcher.search(rewrittenQuery, null, maxSize, sort, false, false);
+                       int size = (pageSize <= 0) ? maxSize : pageSize;
+                       int offset = Math.max(0, (page - 1) * size);
+                       ScoreDoc[] hits = subset(docs.scoreDocs, offset, size);
+                       for (int i = 0; i < hits.length; i++) {
+                               int docId = hits[i].doc;
+                               Document doc = searcher.doc(docId);
+                               QueryResult result = docToQueryResult(doc);
+                               result.docId = docId;
+                               result.totalResults = docs.totalHits;
+                               results.add(result);
+                       }
+               } catch (Exception e) {
+                       log.error(MessageFormat.format("Exception while searching for {0}", queryText), e);
+               }
+               return new ArrayList<QueryResult>(results);
+       }
+
+       private ScoreDoc [] subset(ScoreDoc [] docs, int offset, int size) {
+               if (docs.length >= (offset + size)) {
+                       ScoreDoc [] set = new ScoreDoc[size];
+                       System.arraycopy(docs, offset, set, 0, set.length);
+                       return set;
+               } else if (docs.length >= offset) {
+                       ScoreDoc [] set = new ScoreDoc[docs.length - offset];
+                       System.arraycopy(docs, offset, set, 0, set.length);
+                       return set;
+               } else {
+                       return new ScoreDoc[0];
+               }
+       }
+
+       private IndexWriter getWriter() throws IOException {
+               if (writer == null) {
+                       Directory directory = FSDirectory.open(luceneDir);
+
+                       if (!luceneDir.exists()) {
+                               luceneDir.mkdirs();
+                       }
+
+                       StandardAnalyzer analyzer = new StandardAnalyzer(luceneVersion);
+                       IndexWriterConfig config = new IndexWriterConfig(luceneVersion, analyzer);
+                       config.setOpenMode(OpenMode.CREATE_OR_APPEND);
+                       writer = new IndexWriter(directory, config);
+               }
+               return writer;
+       }
+
+       private synchronized void closeWriter() {
+               try {
+                       if (writer != null) {
+                               writer.close();
+                       }
+               } catch (Exception e) {
+                       log.error("failed to close writer!", e);
+               } finally {
+                       writer = null;
+               }
+       }
+
+       private IndexSearcher getSearcher() throws IOException {
+               if (searcher == null) {
+                       searcher = new IndexSearcher(DirectoryReader.open(getWriter(), true));
+               }
+               return searcher;
+       }
+
+       private synchronized void closeSearcher() {
+               try {
+                       if (searcher != null) {
+                               searcher.getIndexReader().close();
+                       }
+               } catch (Exception e) {
+                       log.error("failed to close searcher!", e);
+               } finally {
+                       searcher = null;
+               }
+       }
+
+       /**
+        * Creates a Lucene document from a ticket.
+        *
+        * @param ticket
+        * @return a Lucene document
+        */
+       private Document ticketToDoc(TicketModel ticket) {
+               Document doc = new Document();
+               // repository and document ids for Lucene querying
+               toDocField(doc, Lucene.rid, StringUtils.getSHA1(ticket.repository));
+               toDocField(doc, Lucene.did, StringUtils.getSHA1(ticket.repository + ticket.number));
+
+               toDocField(doc, Lucene.project, ticket.project);
+               toDocField(doc, Lucene.repository, ticket.repository);
+               toDocField(doc, Lucene.number, ticket.number);
+               toDocField(doc, Lucene.title, ticket.title);
+               toDocField(doc, Lucene.body, ticket.body);
+               toDocField(doc, Lucene.created, ticket.created);
+               toDocField(doc, Lucene.createdby, ticket.createdBy);
+               toDocField(doc, Lucene.updated, ticket.updated);
+               toDocField(doc, Lucene.updatedby, ticket.updatedBy);
+               toDocField(doc, Lucene.responsible, ticket.responsible);
+               toDocField(doc, Lucene.milestone, ticket.milestone);
+               toDocField(doc, Lucene.topic, ticket.topic);
+               toDocField(doc, Lucene.status, ticket.status.name());
+               toDocField(doc, Lucene.comments, ticket.getComments().size());
+               toDocField(doc, Lucene.type, ticket.type == null ? null : ticket.type.name());
+               toDocField(doc, Lucene.mergesha, ticket.mergeSha);
+               toDocField(doc, Lucene.mergeto, ticket.mergeTo);
+               toDocField(doc, Lucene.labels, StringUtils.flattenStrings(ticket.getLabels(), ";").toLowerCase());
+               toDocField(doc, Lucene.participants, StringUtils.flattenStrings(ticket.getParticipants(), ";").toLowerCase());
+               toDocField(doc, Lucene.watchedby, StringUtils.flattenStrings(ticket.getWatchers(), ";").toLowerCase());
+               toDocField(doc, Lucene.mentions, StringUtils.flattenStrings(ticket.getMentions(), ";").toLowerCase());
+               toDocField(doc, Lucene.votes, ticket.getVoters().size());
+
+               List<String> attachments = new ArrayList<String>();
+               for (Attachment attachment : ticket.getAttachments()) {
+                       attachments.add(attachment.name.toLowerCase());
+               }
+               toDocField(doc, Lucene.attachments, StringUtils.flattenStrings(attachments, ";"));
+
+               List<Patchset> patches = ticket.getPatchsets();
+               if (!patches.isEmpty()) {
+                       toDocField(doc, Lucene.patchsets, patches.size());
+                       Patchset patchset = patches.get(patches.size() - 1);
+                       String flat =
+                                       patchset.number + ":" +
+                                       patchset.rev + ":" +
+                                       patchset.tip + ":" +
+                                       patchset.base + ":" +
+                                       patchset.commits;
+                       doc.add(new org.apache.lucene.document.Field(Lucene.patchset.name(), flat, TextField.TYPE_STORED));
+               }
+
+               doc.add(new TextField(Lucene.content.name(), ticket.toIndexableString(), Store.NO));
+
+               return doc;
+       }
+
+       private void toDocField(Document doc, Lucene lucene, Date value) {
+               if (value == null) {
+                       return;
+               }
+               doc.add(new LongField(lucene.name(), value.getTime(), Store.YES));
+       }
+
+       private void toDocField(Document doc, Lucene lucene, long value) {
+               doc.add(new LongField(lucene.name(), value, Store.YES));
+       }
+
+       private void toDocField(Document doc, Lucene lucene, int value) {
+               doc.add(new IntField(lucene.name(), value, Store.YES));
+       }
+
+       private void toDocField(Document doc, Lucene lucene, String value) {
+               if (StringUtils.isEmpty(value)) {
+                       return;
+               }
+               doc.add(new org.apache.lucene.document.Field(lucene.name(), value, TextField.TYPE_STORED));
+       }
+
+       /**
+        * Creates a query result from the Lucene document.  This result is
+        * not a high-fidelity representation of the real ticket, but it is
+        * suitable for display in a table of search results.
+        *
+        * @param doc
+        * @return a query result
+        * @throws ParseException
+        */
+       private QueryResult docToQueryResult(Document doc) throws ParseException {
+               QueryResult result = new QueryResult();
+               result.project = unpackString(doc, Lucene.project);
+               result.repository = unpackString(doc, Lucene.repository);
+               result.number = unpackLong(doc, Lucene.number);
+               result.createdBy = unpackString(doc, Lucene.createdby);
+               result.createdAt = unpackDate(doc, Lucene.created);
+               result.updatedBy = unpackString(doc, Lucene.updatedby);
+               result.updatedAt = unpackDate(doc, Lucene.updated);
+               result.title = unpackString(doc, Lucene.title);
+               result.body = unpackString(doc, Lucene.body);
+               result.status = Status.fromObject(unpackString(doc, Lucene.status), Status.New);
+               result.responsible = unpackString(doc, Lucene.responsible);
+               result.milestone = unpackString(doc, Lucene.milestone);
+               result.topic = unpackString(doc, Lucene.topic);
+               result.type = TicketModel.Type.fromObject(unpackString(doc, Lucene.type), TicketModel.Type.defaultType);
+               result.mergeSha = unpackString(doc, Lucene.mergesha);
+               result.mergeTo = unpackString(doc, Lucene.mergeto);
+               result.commentsCount = unpackInt(doc, Lucene.comments);
+               result.votesCount = unpackInt(doc, Lucene.votes);
+               result.attachments = unpackStrings(doc, Lucene.attachments);
+               result.labels = unpackStrings(doc, Lucene.labels);
+               result.participants = unpackStrings(doc, Lucene.participants);
+               result.watchedby = unpackStrings(doc, Lucene.watchedby);
+               result.mentions = unpackStrings(doc, Lucene.mentions);
+
+               if (!StringUtils.isEmpty(doc.get(Lucene.patchset.name()))) {
+                       // unpack most recent patchset
+                       String [] values = doc.get(Lucene.patchset.name()).split(":", 5);
+
+                       Patchset patchset = new Patchset();
+                       patchset.number = Integer.parseInt(values[0]);
+                       patchset.rev = Integer.parseInt(values[1]);
+                       patchset.tip = values[2];
+                       patchset.base = values[3];
+                       patchset.commits = Integer.parseInt(values[4]);
+
+                       result.patchset = patchset;
+               }
+
+               return result;
+       }
+
+       private String unpackString(Document doc, Lucene lucene) {
+               return doc.get(lucene.name());
+       }
+
+       private List<String> unpackStrings(Document doc, Lucene lucene) {
+               if (!StringUtils.isEmpty(doc.get(lucene.name()))) {
+                       return StringUtils.getStringsFromValue(doc.get(lucene.name()), ";");
+               }
+               return null;
+       }
+
+       private Date unpackDate(Document doc, Lucene lucene) {
+               String val = doc.get(lucene.name());
+               if (!StringUtils.isEmpty(val)) {
+                       long time = Long.parseLong(val);
+                       Date date = new Date(time);
+                       return date;
+               }
+               return null;
+       }
+
+       private long unpackLong(Document doc, Lucene lucene) {
+               String val = doc.get(lucene.name());
+               if (StringUtils.isEmpty(val)) {
+                       return 0;
+               }
+               long l = Long.parseLong(val);
+               return l;
+       }
+
+       private int unpackInt(Document doc, Lucene lucene) {
+               String val = doc.get(lucene.name());
+               if (StringUtils.isEmpty(val)) {
+                       return 0;
+               }
+               int i = Integer.parseInt(val);
+               return i;
+       }
+}
\ No newline at end of file
diff --git a/src/main/java/com/gitblit/tickets/TicketLabel.java b/src/main/java/com/gitblit/tickets/TicketLabel.java
new file mode 100644 (file)
index 0000000..686ce88
--- /dev/null
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2013 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.tickets;
+
+import java.io.Serializable;
+import java.util.List;
+
+import com.gitblit.utils.StringUtils;
+
+/**
+ * A ticket label.
+ *
+ * @author James Moger
+ *
+ */
+public class TicketLabel implements Serializable {
+
+       private static final long serialVersionUID = 1L;
+
+       public final String name;
+
+       public String color;
+
+       public List<QueryResult> tickets;
+
+
+       public TicketLabel(String name) {
+               this.name = name;
+               this.color = StringUtils.getColor(name);
+       }
+
+       public int getTotalTickets() {
+               return tickets == null ? 0 : tickets.size();
+       }
+
+       public int getOpenTickets() {
+               int cnt = 0;
+               if (tickets != null) {
+                       for (QueryResult ticket : tickets) {
+                               if (!ticket.status.isClosed()) {
+                                       cnt++;
+                               }
+                       }
+               }
+               return cnt;
+       }
+
+       public int getClosedTickets() {
+               int cnt = 0;
+               if (tickets != null) {
+                       for (QueryResult ticket : tickets) {
+                               if (ticket.status.isClosed()) {
+                                       cnt++;
+                               }
+                       }
+               }
+               return cnt;
+       }
+
+       @Override
+       public String toString() {
+               return name;
+       }
+}
diff --git a/src/main/java/com/gitblit/tickets/TicketMilestone.java b/src/main/java/com/gitblit/tickets/TicketMilestone.java
new file mode 100644 (file)
index 0000000..c6b4fcc
--- /dev/null
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2013 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.tickets;
+
+import java.util.Date;
+
+import com.gitblit.models.TicketModel.Status;
+
+/**
+ * A ticket milestone.
+ *
+ * @author James Moger
+ *
+ */
+public class TicketMilestone extends TicketLabel {
+
+       private static final long serialVersionUID = 1L;
+
+       public Status status;
+
+       public Date due;
+
+       public TicketMilestone(String name) {
+               super(name);
+               status = Status.Open;
+       }
+
+       public int getProgress() {
+               int total = getTotalTickets();
+               if (total == 0) {
+                       return 0;
+               }
+               return (int) (((getClosedTickets() * 1f) / (total * 1f)) * 100);
+       }
+
+       @Override
+       public String toString() {
+               return name;
+       }
+}
diff --git a/src/main/java/com/gitblit/tickets/TicketNotifier.java b/src/main/java/com/gitblit/tickets/TicketNotifier.java
new file mode 100644 (file)
index 0000000..b4c3bae
--- /dev/null
@@ -0,0 +1,617 @@
+/*
+ * Copyright 2014 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.tickets;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.text.DateFormat;
+import java.text.MessageFormat;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.log4j.Logger;
+import org.eclipse.jgit.diff.DiffEntry.ChangeType;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.slf4j.LoggerFactory;
+
+import com.gitblit.Constants;
+import com.gitblit.IStoredSettings;
+import com.gitblit.Keys;
+import com.gitblit.git.PatchsetCommand;
+import com.gitblit.manager.INotificationManager;
+import com.gitblit.manager.IRepositoryManager;
+import com.gitblit.manager.IRuntimeManager;
+import com.gitblit.manager.IUserManager;
+import com.gitblit.models.Mailing;
+import com.gitblit.models.PathModel.PathChangeModel;
+import com.gitblit.models.RepositoryModel;
+import com.gitblit.models.TicketModel;
+import com.gitblit.models.TicketModel.Change;
+import com.gitblit.models.TicketModel.Field;
+import com.gitblit.models.TicketModel.Patchset;
+import com.gitblit.models.TicketModel.Review;
+import com.gitblit.models.TicketModel.Status;
+import com.gitblit.models.UserModel;
+import com.gitblit.utils.ArrayUtils;
+import com.gitblit.utils.DiffUtils;
+import com.gitblit.utils.DiffUtils.DiffStat;
+import com.gitblit.utils.JGitUtils;
+import com.gitblit.utils.MarkdownUtils;
+import com.gitblit.utils.StringUtils;
+
+/**
+ * Formats and queues ticket/patch notifications for dispatch to the
+ * mail executor upon completion of a push or a ticket update.  Messages are
+ * created as Markdown and then transformed to html.
+ *
+ * @author James Moger
+ *
+ */
+public class TicketNotifier {
+
+       protected final Map<Long, Mailing> queue = new TreeMap<Long, Mailing>();
+
+       private final String SOFT_BRK = "\n";
+
+       private final String HARD_BRK = "\n\n";
+
+       private final String HR = "----\n\n";
+
+       private final IStoredSettings settings;
+
+       private final INotificationManager notificationManager;
+
+       private final IUserManager userManager;
+
+       private final IRepositoryManager repositoryManager;
+
+       private final ITicketService ticketService;
+
+       private final String addPattern = "<span style=\"color:darkgreen;\">+{0}</span>";
+       private final String delPattern = "<span style=\"color:darkred;\">-{0}</span>";
+
+       public TicketNotifier(
+                       IRuntimeManager runtimeManager,
+                       INotificationManager notificationManager,
+                       IUserManager userManager,
+                       IRepositoryManager repositoryManager,
+                       ITicketService ticketService) {
+
+               this.settings = runtimeManager.getSettings();
+               this.notificationManager = notificationManager;
+               this.userManager = userManager;
+               this.repositoryManager = repositoryManager;
+               this.ticketService = ticketService;
+       }
+
+       public void sendAll() {
+               for (Mailing mail : queue.values()) {
+                       notificationManager.send(mail);
+               }
+       }
+
+       public void sendMailing(TicketModel ticket) {
+               queueMailing(ticket);
+               sendAll();
+       }
+
+       /**
+        * Queues an update notification.
+        *
+        * @param ticket
+        * @return a notification object used for testing
+        */
+       public Mailing queueMailing(TicketModel ticket) {
+               try {
+                       // format notification message
+                       String markdown = formatLastChange(ticket);
+
+                       StringBuilder html = new StringBuilder();
+                       html.append("<head>");
+                       html.append(readStyle());
+                       html.append("</head>");
+                       html.append("<body>");
+                       html.append(MarkdownUtils.transformGFM(settings, markdown, ticket.repository));
+                       html.append("</body>");
+
+                       Mailing mailing = Mailing.newHtml();
+                       mailing.from = getUserModel(ticket.updatedBy == null ? ticket.createdBy : ticket.updatedBy).getDisplayName();
+                       mailing.subject = getSubject(ticket);
+                       mailing.content = html.toString();
+                       mailing.id = "ticket." + ticket.number + "." + StringUtils.getSHA1(ticket.repository + ticket.number);
+
+                       setRecipients(ticket, mailing);
+                       queue.put(ticket.number, mailing);
+
+                       return mailing;
+               } catch (Exception e) {
+                       Logger.getLogger(getClass()).error("failed to queue mailing for #" + ticket.number, e);
+               }
+               return null;
+       }
+
+       protected String getSubject(TicketModel ticket) {
+               Change lastChange = ticket.changes.get(ticket.changes.size() - 1);
+               boolean newTicket = lastChange.isStatusChange() && ticket.changes.size() == 1;
+               String re = newTicket ? "" : "Re: ";
+               String subject = MessageFormat.format("{0}[{1}] {2} (#{3,number,0})",
+                               re, StringUtils.stripDotGit(ticket.repository), ticket.title, ticket.number);
+               return subject;
+       }
+
+       protected String formatLastChange(TicketModel ticket) {
+               Change lastChange = ticket.changes.get(ticket.changes.size() - 1);
+               UserModel user = getUserModel(lastChange.author);
+
+               // define the fields we do NOT want to see in an email notification
+               Set<TicketModel.Field> fieldExclusions = new HashSet<TicketModel.Field>();
+               fieldExclusions.addAll(Arrays.asList(Field.watchers, Field.voters));
+
+               StringBuilder sb = new StringBuilder();
+               boolean newTicket = false;
+               boolean isFastForward = true;
+               List<RevCommit> commits = null;
+               DiffStat diffstat = null;
+
+               String pattern;
+               if (lastChange.isStatusChange()) {
+                       Status state = lastChange.getStatus();
+                       switch (state) {
+                       case New:
+                               // new ticket
+                               newTicket = true;
+                               fieldExclusions.add(Field.status);
+                               fieldExclusions.add(Field.title);
+                               fieldExclusions.add(Field.body);
+                               if (lastChange.hasPatchset()) {
+                                       pattern = "**{0}** is proposing a change.";
+                               } else {
+                                       pattern = "**{0}** created this ticket.";
+                               }
+                               sb.append(MessageFormat.format(pattern, user.getDisplayName()));
+                               break;
+                       default:
+                               // some form of resolved
+                               if (lastChange.hasField(Field.mergeSha)) {
+                                       // closed by push (merged patchset)
+                                       pattern = "**{0}** closed this ticket by pushing {1} to {2}.";
+
+                                       // identify patch that closed the ticket
+                                       String merged = ticket.mergeSha;
+                                       for (Patchset patchset : ticket.getPatchsets()) {
+                                               if (patchset.tip.equals(ticket.mergeSha)) {
+                                                       merged = patchset.toString();
+                                                       break;
+                                               }
+                                       }
+                                       sb.append(MessageFormat.format(pattern, user.getDisplayName(), merged, ticket.mergeTo));
+                               } else {
+                                       // workflow status change by user
+                                       pattern = "**{0}** changed the status of this ticket to **{1}**.";
+                                       sb.append(MessageFormat.format(pattern, user.getDisplayName(), lastChange.getStatus().toString().toUpperCase()));
+                               }
+                               break;
+                       }
+                       sb.append(HARD_BRK);
+               } else if (lastChange.hasPatchset()) {
+                       // patchset uploaded
+                       Patchset patchset = lastChange.patchset;
+                       String base = "";
+                       // determine the changed paths
+                       Repository repo = null;
+                       try {
+                               repo = repositoryManager.getRepository(ticket.repository);
+                               if (patchset.isFF() && (patchset.rev > 1)) {
+                                       // fast-forward update, just show the new data
+                                       isFastForward = true;
+                                       Patchset prev = ticket.getPatchset(patchset.number, patchset.rev - 1);
+                                       base = prev.tip;
+                               } else {
+                                       // proposal OR non-fast-forward update
+                                       isFastForward = false;
+                                       base = patchset.base;
+                               }
+
+                               diffstat = DiffUtils.getDiffStat(repo, base, patchset.tip);
+                               commits = JGitUtils.getRevLog(repo, base, patchset.tip);
+                       } catch (Exception e) {
+                               Logger.getLogger(getClass()).error("failed to get changed paths", e);
+                       } finally {
+                               repo.close();
+                       }
+
+                       // describe the patchset
+                       String compareUrl = ticketService.getCompareUrl(ticket, base, patchset.tip);
+                       if (patchset.isFF()) {
+                               pattern = "**{0}** added {1} {2} to patchset {3}.";
+                               sb.append(MessageFormat.format(pattern, user.getDisplayName(), patchset.added, patchset.added == 1 ? "commit" : "commits", patchset.number));
+                       } else {
+                               pattern = "**{0}** uploaded patchset {1}. *({2})*";
+                               sb.append(MessageFormat.format(pattern, user.getDisplayName(), patchset.number, patchset.type.toString().toUpperCase()));
+                       }
+                       sb.append(HARD_BRK);
+                       sb.append(MessageFormat.format("{0} {1}, {2} {3}, <span style=\"color:darkgreen;\">+{4} insertions</span>, <span style=\"color:darkred;\">-{5} deletions</span> from {6}. [compare]({7})",
+                                       commits.size(), commits.size() == 1 ? "commit" : "commits",
+                                       diffstat.paths.size(),
+                                       diffstat.paths.size() == 1 ? "file" : "files",
+                                       diffstat.getInsertions(),
+                                       diffstat.getDeletions(),
+                                       isFastForward ? "previous revision" : "merge base",
+                                       compareUrl));
+
+                       // note commit additions on a rebase,if any
+                       switch (lastChange.patchset.type) {
+                       case Rebase:
+                               if (lastChange.patchset.added > 0) {
+                                       sb.append(SOFT_BRK);
+                                       sb.append(MessageFormat.format("{0} {1} added.", lastChange.patchset.added, lastChange.patchset.added == 1 ? "commit" : "commits"));
+                               }
+                               break;
+                       default:
+                               break;
+                       }
+                       sb.append(HARD_BRK);
+               } else if (lastChange.hasReview()) {
+                       // review
+                       Review review = lastChange.review;
+                       pattern = "**{0}** has reviewed patchset {1,number,0} revision {2,number,0}.";
+                       sb.append(MessageFormat.format(pattern, user.getDisplayName(), review.patchset, review.rev));
+                       sb.append(HARD_BRK);
+
+                       String d = settings.getString(Keys.web.datestampShortFormat, "yyyy-MM-dd");
+                       String t = settings.getString(Keys.web.timeFormat, "HH:mm");
+                       DateFormat df = new SimpleDateFormat(d + " " + t);
+                       List<Change> reviews = ticket.getReviews(ticket.getPatchset(review.patchset, review.rev));
+                       sb.append("| Date | Reviewer      | Score | Description  |\n");
+                       sb.append("| :--- | :------------ | :---: | :----------- |\n");
+                       for (Change change : reviews) {
+                               String name = change.author;
+                               UserModel u = userManager.getUserModel(change.author);
+                               if (u != null) {
+                                       name = u.getDisplayName();
+                               }
+                               String score;
+                               switch (change.review.score) {
+                               case approved:
+                                       score = MessageFormat.format(addPattern, change.review.score.getValue());
+                                       break;
+                               case vetoed:
+                                       score = MessageFormat.format(delPattern, Math.abs(change.review.score.getValue()));
+                                       break;
+                               default:
+                                       score = "" + change.review.score.getValue();
+                               }
+                               String date = df.format(change.date);
+                               sb.append(String.format("| %1$s | %2$s | %3$s | %4$s |\n",
+                                               date, name, score, change.review.score.toString()));
+                       }
+                       sb.append(HARD_BRK);
+               } else if (lastChange.hasComment()) {
+                       // comment update
+                       sb.append(MessageFormat.format("**{0}** commented on this ticket.", user.getDisplayName()));
+                       sb.append(HARD_BRK);
+               } else {
+                       // general update
+                       pattern = "**{0}** has updated this ticket.";
+                       sb.append(MessageFormat.format(pattern, user.getDisplayName()));
+                       sb.append(HARD_BRK);
+               }
+
+               // ticket link
+               sb.append(MessageFormat.format("[view ticket {0,number,0}]({1})",
+                               ticket.number, ticketService.getTicketUrl(ticket)));
+               sb.append(HARD_BRK);
+
+               if (newTicket) {
+                       // ticket title
+                       sb.append(MessageFormat.format("### {0}", ticket.title));
+                       sb.append(HARD_BRK);
+
+                       // ticket description, on state change
+                       if (StringUtils.isEmpty(ticket.body)) {
+                               sb.append("<span style=\"color: #888;\">no description entered</span>");
+                       } else {
+                               sb.append(ticket.body);
+                       }
+                       sb.append(HARD_BRK);
+                       sb.append(HR);
+               }
+
+               // field changes
+               if (lastChange.hasFieldChanges()) {
+                       Map<Field, String> filtered = new HashMap<Field, String>();
+                       for (Map.Entry<Field, String> fc : lastChange.fields.entrySet()) {
+                               if (!fieldExclusions.contains(fc.getKey())) {
+                                       // field is included
+                                       filtered.put(fc.getKey(), fc.getValue());
+                               }
+                       }
+
+                       // sort by field ordinal
+                       List<Field> fields = new ArrayList<Field>(filtered.keySet());
+                       Collections.sort(fields);
+
+                       if (filtered.size() > 0) {
+                               sb.append(HARD_BRK);
+                               sb.append("| Field Changes               ||\n");
+                               sb.append("| ------------: | :----------- |\n");
+                               for (Field field : fields) {
+                                       String value;
+                                       if (filtered.get(field) == null) {
+                                               value = "";
+                                       } else {
+                                               value = filtered.get(field).replace("\r\n", "<br/>").replace("\n", "<br/>").replace("|", "&#124;");
+                                       }
+                                       sb.append(String.format("| **%1$s:** | %2$s |\n", field.name(), value));
+                               }
+                               sb.append(HARD_BRK);
+                       }
+               }
+
+               // new comment
+               if (lastChange.hasComment()) {
+                       sb.append(HR);
+                       sb.append(lastChange.comment.text);
+                       sb.append(HARD_BRK);
+               }
+
+               // insert the patchset details and review instructions
+               if (lastChange.hasPatchset() && ticket.isOpen()) {
+                       if (commits != null && commits.size() > 0) {
+                               // append the commit list
+                               String title = isFastForward ? "Commits added to previous patchset revision" : "All commits in patchset";
+                               sb.append(MessageFormat.format("| {0} |||\n", title));
+                               sb.append("| SHA | Author | Title |\n");
+                               sb.append("| :-- | :----- | :---- |\n");
+                               for (RevCommit commit : commits) {
+                                       sb.append(MessageFormat.format("| {0} | {1} | {2} |\n",
+                                                       commit.getName(), commit.getAuthorIdent().getName(),
+                                                       StringUtils.trimString(commit.getShortMessage(), Constants.LEN_SHORTLOG).replace("|", "&#124;")));
+                               }
+                               sb.append(HARD_BRK);
+                       }
+
+                       if (diffstat != null) {
+                               // append the changed path list
+                               String title = isFastForward ? "Files changed since previous patchset revision" : "All files changed in patchset";
+                               sb.append(MessageFormat.format("| {0} |||\n", title));
+                               sb.append("| :-- | :----------- | :-: |\n");
+                               for (PathChangeModel path : diffstat.paths) {
+                                       String add = MessageFormat.format(addPattern, path.insertions);
+                                       String del = MessageFormat.format(delPattern, path.deletions);
+                                       String diff = null;
+                                       switch (path.changeType) {
+                                       case ADD:
+                                               diff = add;
+                                               break;
+                                       case DELETE:
+                                               diff = del;
+                                               break;
+                                       case MODIFY:
+                                               if (path.insertions > 0 && path.deletions > 0) {
+                                                       // insertions & deletions
+                                                       diff = add + "/" + del;
+                                               } else if (path.insertions > 0) {
+                                                       // just insertions
+                                                       diff = add;
+                                               } else {
+                                                       // just deletions
+                                                       diff = del;
+                                               }
+                                               break;
+                                       default:
+                                               diff = path.changeType.name();
+                                               break;
+                                       }
+                                       sb.append(MessageFormat.format("| {0} | {1} | {2} |\n",
+                                                       getChangeType(path.changeType), path.name, diff));
+                               }
+                               sb.append(HARD_BRK);
+                       }
+
+                       sb.append(formatPatchsetInstructions(ticket, lastChange.patchset));
+               }
+
+               return sb.toString();
+       }
+
+       protected String getChangeType(ChangeType type) {
+               String style = null;
+               switch (type) {
+                       case ADD:
+                               style = "color:darkgreen;";
+                               break;
+                       case COPY:
+                               style = "";
+                               break;
+                       case DELETE:
+                               style = "color:darkred;";
+                               break;
+                       case MODIFY:
+                               style = "";
+                               break;
+                       case RENAME:
+                               style = "";
+                               break;
+                       default:
+                               break;
+               }
+               String code = type.name().toUpperCase().substring(0, 1);
+               if (style == null) {
+                       return code;
+               } else {
+                       return MessageFormat.format("<strong><span style=\"{0}padding:2px;margin:2px;border:1px solid #ddd;\">{1}</span></strong>", style, code);
+               }
+       }
+
+       /**
+        * Generates patchset review instructions for command-line git
+        *
+        * @param patchset
+        * @return instructions
+        */
+       protected String formatPatchsetInstructions(TicketModel ticket, Patchset patchset) {
+               String canonicalUrl = settings.getString(Keys.web.canonicalUrl, "https://localhost:8443");
+               String repositoryUrl = canonicalUrl + Constants.R_PATH + ticket.repository;
+
+               String ticketBranch = Repository.shortenRefName(PatchsetCommand.getTicketBranch(ticket.number));
+               String patchsetBranch  = PatchsetCommand.getPatchsetBranch(ticket.number, patchset.number);
+               String reviewBranch = PatchsetCommand.getReviewBranch(ticket.number);
+
+               String instructions = readResource("commands.md");
+               instructions = instructions.replace("${ticketId}", "" + ticket.number);
+               instructions = instructions.replace("${patchset}", "" + patchset.number);
+               instructions = instructions.replace("${repositoryUrl}", repositoryUrl);
+               instructions = instructions.replace("${ticketRef}", ticketBranch);
+               instructions = instructions.replace("${patchsetRef}", patchsetBranch);
+               instructions = instructions.replace("${reviewBranch}", reviewBranch);
+
+               return instructions;
+       }
+
+       /**
+        * Gets the usermodel for the username.  Creates a temp model, if required.
+        *
+        * @param username
+        * @return a usermodel
+        */
+       protected UserModel getUserModel(String username) {
+               UserModel user = userManager.getUserModel(username);
+               if (user == null) {
+                       // create a temporary user model (for unit tests)
+                       user = new UserModel(username);
+               }
+               return user;
+       }
+
+       /**
+        * Set the proper recipients for a ticket.
+        *
+        * @param ticket
+        * @param mailing
+        */
+       protected void setRecipients(TicketModel ticket, Mailing mailing) {
+               RepositoryModel repository = repositoryManager.getRepositoryModel(ticket.repository);
+
+               //
+               // Direct TO recipients
+               //
+               Set<String> toAddresses = new TreeSet<String>();
+               for (String name : ticket.getParticipants()) {
+                       UserModel user = userManager.getUserModel(name);
+                       if (user != null) {
+                               if (!StringUtils.isEmpty(user.emailAddress)) {
+                                       if (user.canView(repository)) {
+                                               toAddresses.add(user.emailAddress);
+                                       } else {
+                                               LoggerFactory.getLogger(getClass()).warn(
+                                                               MessageFormat.format("ticket {0}-{1,number,0}: {2} can not receive notification",
+                                                                               repository.name, ticket.number, user.username));
+                                       }
+                               }
+                       }
+               }
+               mailing.setRecipients(toAddresses);
+
+               //
+               // CC recipients
+               //
+               Set<String> ccs = new TreeSet<String>();
+
+               // cc users mentioned in last comment
+               Change lastChange = ticket.changes.get(ticket.changes.size() - 1);
+               if (lastChange.hasComment()) {
+                       Pattern p = Pattern.compile("\\s@([A-Za-z0-9-_]+)");
+                       Matcher m = p.matcher(lastChange.comment.text);
+                       while (m.find()) {
+                               String username = m.group();
+                               ccs.add(username);
+                       }
+               }
+
+               // cc users who are watching the ticket
+               ccs.addAll(ticket.getWatchers());
+
+               // TODO cc users who are watching the repository
+
+               Set<String> ccAddresses = new TreeSet<String>();
+               for (String name : ccs) {
+                       UserModel user = userManager.getUserModel(name);
+                       if (user != null) {
+                               if (!StringUtils.isEmpty(user.emailAddress)) {
+                                       if (user.canView(repository)) {
+                                               ccAddresses.add(user.emailAddress);
+                                       } else {
+                                               LoggerFactory.getLogger(getClass()).warn(
+                                                               MessageFormat.format("ticket {0}-{1,number,0}: {2} can not receive notification",
+                                                                               repository.name, ticket.number, user.username));
+                                       }
+                               }
+                       }
+               }
+
+               // cc repository mailing list addresses
+               if (!ArrayUtils.isEmpty(repository.mailingLists)) {
+                       ccAddresses.addAll(repository.mailingLists);
+               }
+               ccAddresses.addAll(settings.getStrings(Keys.mail.mailingLists));
+
+               mailing.setCCs(ccAddresses);
+       }
+
+       protected String readStyle() {
+               StringBuilder sb = new StringBuilder();
+               sb.append("<style>\n");
+               sb.append(readResource("email.css"));
+               sb.append("</style>\n");
+               return sb.toString();
+       }
+
+       protected String readResource(String resource) {
+               StringBuilder sb = new StringBuilder();
+               InputStream is = null;
+               try {
+                       is = getClass().getResourceAsStream(resource);
+                       List<String> lines = IOUtils.readLines(is);
+                       for (String line : lines) {
+                               sb.append(line).append('\n');
+                       }
+               } catch (IOException e) {
+
+               } finally {
+                       if (is != null) {
+                               try {
+                                       is.close();
+                               } catch (IOException e) {
+                               }
+                       }
+               }
+               return sb.toString();
+       }
+}
diff --git a/src/main/java/com/gitblit/tickets/TicketResponsible.java b/src/main/java/com/gitblit/tickets/TicketResponsible.java
new file mode 100644 (file)
index 0000000..12621c6
--- /dev/null
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2014 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.tickets;
+
+import java.io.Serializable;
+
+import org.parboiled.common.StringUtils;
+
+import com.gitblit.models.UserModel;
+
+/**
+ * A ticket responsible.
+ *
+ * @author James Moger
+ *
+ */
+public class TicketResponsible implements Serializable, Comparable<TicketResponsible> {
+
+       private static final long serialVersionUID = 1L;
+
+       public final String displayname;
+
+       public final String username;
+
+       public final String email;
+
+       public TicketResponsible(UserModel user) {
+               this(user.getDisplayName(), user.username, user.emailAddress);
+       }
+
+       public TicketResponsible(String displayname, String username, String email) {
+               this.displayname = displayname;
+               this.username = username;
+               this.email = email;
+       }
+
+       @Override
+       public String toString() {
+               return displayname + (StringUtils.isEmpty(username) ? "" : (" (" + username + ")"));
+       }
+
+       @Override
+       public int compareTo(TicketResponsible o) {
+               return toString().compareTo(o.toString());
+       }
+}
diff --git a/src/main/java/com/gitblit/tickets/TicketSerializer.java b/src/main/java/com/gitblit/tickets/TicketSerializer.java
new file mode 100644 (file)
index 0000000..2a71af3
--- /dev/null
@@ -0,0 +1,175 @@
+/*
+ * Copyright 2013 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.tickets;
+
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Date;
+import java.util.List;
+
+import com.gitblit.models.TicketModel;
+import com.gitblit.models.TicketModel.Change;
+import com.gitblit.models.TicketModel.Score;
+import com.gitblit.utils.ArrayUtils;
+import com.gitblit.utils.JsonUtils.ExcludeField;
+import com.gitblit.utils.JsonUtils.GmtDateTypeAdapter;
+import com.google.gson.ExclusionStrategy;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonPrimitive;
+import com.google.gson.JsonSerializationContext;
+import com.google.gson.JsonSerializer;
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.reflect.TypeToken;
+
+/**
+ * Serializes and deserializes tickets, change, and journals.
+ *
+ * @author James Moger
+ *
+ */
+public class TicketSerializer {
+
+       protected static final Type JOURNAL_TYPE = new TypeToken<Collection<Change>>() {}.getType();
+
+       public static List<Change> deserializeJournal(String json) {
+               Collection<Change> list = gson().fromJson(json, JOURNAL_TYPE);
+               return new ArrayList<Change>(list);
+       }
+
+       public static TicketModel deserializeTicket(String json) {
+               return gson().fromJson(json, TicketModel.class);
+       }
+
+       public static TicketLabel deserializeLabel(String json) {
+               return gson().fromJson(json, TicketLabel.class);
+       }
+
+       public static TicketMilestone deserializeMilestone(String json) {
+               return gson().fromJson(json, TicketMilestone.class);
+       }
+
+
+       public static String serializeJournal(List<Change> changes) {
+               try {
+                       Gson gson = gson();
+                       return gson.toJson(changes);
+               } catch (Exception e) {
+                       // won't happen
+               }
+               return null;
+       }
+
+       public static String serialize(TicketModel ticket) {
+               if (ticket == null) {
+                       return null;
+               }
+               try {
+                       Gson gson = gson(
+                                       new ExcludeField("com.gitblit.models.TicketModel$Attachment.content"),
+                                       new ExcludeField("com.gitblit.models.TicketModel$Attachment.deleted"),
+                                       new ExcludeField("com.gitblit.models.TicketModel$Comment.deleted"));
+                       return gson.toJson(ticket);
+               } catch (Exception e) {
+                       // won't happen
+               }
+               return null;
+       }
+
+       public static String serialize(Change change) {
+               if (change == null) {
+                       return null;
+               }
+               try {
+                       Gson gson = gson(
+                                       new ExcludeField("com.gitblit.models.TicketModel$Attachment.content"));
+                       return gson.toJson(change);
+               } catch (Exception e) {
+                       // won't happen
+               }
+               return null;
+       }
+
+       public static String serialize(TicketLabel label) {
+               if (label == null) {
+                       return null;
+               }
+               try {
+                       Gson gson = gson();
+                       return gson.toJson(label);
+               } catch (Exception e) {
+                       // won't happen
+               }
+               return null;
+       }
+
+       public static String serialize(TicketMilestone milestone) {
+               if (milestone == null) {
+                       return null;
+               }
+               try {
+                       Gson gson = gson();
+                       return gson.toJson(milestone);
+               } catch (Exception e) {
+                       // won't happen
+               }
+               return null;
+       }
+
+       // build custom gson instance with GMT date serializer/deserializer
+       // http://code.google.com/p/google-gson/issues/detail?id=281
+       public static Gson gson(ExclusionStrategy... strategies) {
+               GsonBuilder builder = new GsonBuilder();
+               builder.registerTypeAdapter(Date.class, new GmtDateTypeAdapter());
+               builder.registerTypeAdapter(Score.class, new ScoreTypeAdapter());
+               if (!ArrayUtils.isEmpty(strategies)) {
+                       builder.setExclusionStrategies(strategies);
+               }
+               return builder.create();
+       }
+
+       private static class ScoreTypeAdapter implements JsonSerializer<Score>, JsonDeserializer<Score> {
+
+               private ScoreTypeAdapter() {
+               }
+
+               @Override
+               public synchronized JsonElement serialize(Score score, Type type,
+                               JsonSerializationContext jsonSerializationContext) {
+                               return new JsonPrimitive(score.getValue());
+               }
+
+               @Override
+               public synchronized Score deserialize(JsonElement jsonElement, Type type,
+                               JsonDeserializationContext jsonDeserializationContext) {
+                       try {
+                               int value = jsonElement.getAsInt();
+                               for (Score score : Score.values()) {
+                                       if (score.getValue() == value) {
+                                               return score;
+                                       }
+                               }
+                               return Score.not_reviewed;
+                       } catch (Exception e) {
+                               throw new JsonSyntaxException(jsonElement.getAsString(), e);
+                       }
+               }
+       }
+}
diff --git a/src/main/java/com/gitblit/tickets/commands.md b/src/main/java/com/gitblit/tickets/commands.md
new file mode 100644 (file)
index 0000000..25c24f4
--- /dev/null
@@ -0,0 +1,11 @@
+#### To review with Git
+
+on a detached HEAD...
+
+    git fetch ${repositoryUrl} ${ticketRef} && git checkout FETCH_HEAD
+
+on a new branch...
+
+    git fetch ${repositoryUrl} ${ticketRef} && git checkout -B ${reviewBranch} FETCH_HEAD
+
+
diff --git a/src/main/java/com/gitblit/tickets/email.css b/src/main/java/com/gitblit/tickets/email.css
new file mode 100644 (file)
index 0000000..3b81542
--- /dev/null
@@ -0,0 +1,38 @@
+table {
+       border:1px solid #ddd;
+       margin: 15px 0px;
+}
+
+th {
+       font-weight: bold;
+       border-bottom: 1px solid #ddd;
+}
+
+td, th {
+       padding: 4px 8px;
+       vertical-align: top;
+}
+
+a {
+       color: #2F58A0;
+}
+
+a:hover {
+       color: #002060;
+}
+
+body {
+       color: black;
+}
+
+pre {
+       background-color: rgb(250, 250, 250);
+       border: 1px solid rgb(221, 221, 221);
+    border-radius: 4px 4px 4px 4px;
+    display: block;
+    font-size: 12px;
+    line-height: 18px;
+    margin: 9px 0;
+    padding: 8.5px;
+    white-space: pre-wrap;
+}
index 6a6085e7c465fb5ea74a8eb38970a57c03fcd957..6f3b08560fd8858fe90001701805c09b72c758d7 100644 (file)
@@ -59,6 +59,8 @@ import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.lib.RepositoryCache.FileKey;\r
 import org.eclipse.jgit.lib.StoredConfig;\r
 import org.eclipse.jgit.lib.TreeFormatter;\r
+import org.eclipse.jgit.merge.MergeStrategy;
+import org.eclipse.jgit.merge.RecursiveMerger;
 import org.eclipse.jgit.revwalk.RevBlob;\r
 import org.eclipse.jgit.revwalk.RevCommit;\r
 import org.eclipse.jgit.revwalk.RevObject;\r
@@ -82,6 +84,7 @@ import org.eclipse.jgit.util.FS;
 import org.slf4j.Logger;\r
 import org.slf4j.LoggerFactory;\r
 \r
+import com.gitblit.GitBlitException;
 import com.gitblit.models.GitNote;\r
 import com.gitblit.models.PathModel;\r
 import com.gitblit.models.PathModel.PathChangeModel;\r
@@ -2145,4 +2148,208 @@ public class JGitUtils {
                }\r
                return false;\r
        }\r
+
+       /**
+        * Returns true if the commit identified by commitId is an ancestor or the
+        * the commit identified by tipId.
+        *
+        * @param repository
+        * @param commitId
+        * @param tipId
+        * @return true if there is the commit is an ancestor of the tip
+        */
+       public static boolean isMergedInto(Repository repository, String commitId, String tipId) {
+               try {
+                       return isMergedInto(repository, repository.resolve(commitId), repository.resolve(tipId));
+               } catch (Exception e) {
+                       LOGGER.error("Failed to determine isMergedInto", e);
+               }
+               return false;
+       }
+
+       /**
+        * Returns true if the commit identified by commitId is an ancestor or the
+        * the commit identified by tipId.
+        *
+        * @param repository
+        * @param commitId
+        * @param tipId
+        * @return true if there is the commit is an ancestor of the tip
+        */
+       public static boolean isMergedInto(Repository repository, ObjectId commitId, ObjectId tipCommitId) {
+               // traverse the revlog looking for a commit chain between the endpoints
+               RevWalk rw = new RevWalk(repository);
+               try {
+                       // must re-lookup RevCommits to workaround undocumented RevWalk bug
+                       RevCommit tip = rw.lookupCommit(tipCommitId);
+                       RevCommit commit = rw.lookupCommit(commitId);
+                       return rw.isMergedInto(commit, tip);
+               } catch (Exception e) {
+                       LOGGER.error("Failed to determine isMergedInto", e);
+               } finally {
+                       rw.dispose();
+               }
+               return false;
+       }
+
+       /**
+        * Returns the merge base of two commits or null if there is no common
+        * ancestry.
+        *
+        * @param repository
+        * @param commitIdA
+        * @param commitIdB
+        * @return the commit id of the merge base or null if there is no common base
+        */
+       public static String getMergeBase(Repository repository, ObjectId commitIdA, ObjectId commitIdB) {
+               RevWalk rw = new RevWalk(repository);
+               try {
+                       RevCommit a = rw.lookupCommit(commitIdA);
+                       RevCommit b = rw.lookupCommit(commitIdB);
+
+                       rw.setRevFilter(RevFilter.MERGE_BASE);
+                       rw.markStart(a);
+                       rw.markStart(b);
+                       RevCommit mergeBase = rw.next();
+                       if (mergeBase == null) {
+                               return null;
+                       }
+                       return mergeBase.getName();
+               } catch (Exception e) {
+                       LOGGER.error("Failed to determine merge base", e);
+               } finally {
+                       rw.dispose();
+               }
+               return null;
+       }
+
+       public static enum MergeStatus {
+               NOT_MERGEABLE, FAILED, ALREADY_MERGED, MERGEABLE, MERGED;
+       }
+
+       /**
+        * Determines if we can cleanly merge one branch into another.  Returns true
+        * if we can merge without conflict, otherwise returns false.
+        *
+        * @param repository
+        * @param src
+        * @param toBranch
+        * @return true if we can merge without conflict
+        */
+       public static MergeStatus canMerge(Repository repository, String src, String toBranch) {
+               RevWalk revWalk = null;
+               try {
+                       revWalk = new RevWalk(repository);
+                       RevCommit branchTip = revWalk.lookupCommit(repository.resolve(toBranch));
+                       RevCommit srcTip = revWalk.lookupCommit(repository.resolve(src));
+                       if (revWalk.isMergedInto(srcTip, branchTip)) {
+                               // already merged
+                               return MergeStatus.ALREADY_MERGED;
+                       } else if (revWalk.isMergedInto(branchTip, srcTip)) {
+                               // fast-forward
+                               return MergeStatus.MERGEABLE;
+                       }
+                       RecursiveMerger merger = (RecursiveMerger) MergeStrategy.RECURSIVE.newMerger(repository, true);
+                       boolean canMerge = merger.merge(branchTip, srcTip);
+                       if (canMerge) {
+                               return MergeStatus.MERGEABLE;
+                       }
+               } catch (IOException e) {
+                       LOGGER.error("Failed to determine canMerge", e);
+               } finally {
+                       revWalk.release();
+               }
+               return MergeStatus.NOT_MERGEABLE;
+       }
+
+
+       public static class MergeResult {
+               public final MergeStatus status;
+               public final String sha;
+
+               MergeResult(MergeStatus status, String sha) {
+                       this.status = status;
+                       this.sha = sha;
+               }
+       }
+
+       /**
+        * Tries to merge a commit into a branch.  If there are conflicts, the merge
+        * will fail.
+        *
+        * @param repository
+        * @param src
+        * @param toBranch
+        * @param committer
+        * @param message
+        * @return the merge result
+        */
+       public static MergeResult merge(Repository repository, String src, String toBranch,
+                       PersonIdent committer, String message) {
+
+               if (!toBranch.startsWith(Constants.R_REFS)) {
+                       // branch ref doesn't start with ref, assume this is a branch head
+                       toBranch = Constants.R_HEADS + toBranch;
+               }
+
+               RevWalk revWalk = null;
+               try {
+                       revWalk = new RevWalk(repository);
+                       RevCommit branchTip = revWalk.lookupCommit(repository.resolve(toBranch));
+                       RevCommit srcTip = revWalk.lookupCommit(repository.resolve(src));
+                       if (revWalk.isMergedInto(srcTip, branchTip)) {
+                               // already merged
+                               return new MergeResult(MergeStatus.ALREADY_MERGED, null);
+                       }
+                       RecursiveMerger merger = (RecursiveMerger) MergeStrategy.RECURSIVE.newMerger(repository, true);
+                       boolean merged = merger.merge(branchTip, srcTip);
+                       if (merged) {
+                               // create a merge commit and a reference to track the merge commit
+                               ObjectId treeId = merger.getResultTreeId();
+                               ObjectInserter odi = repository.newObjectInserter();
+                               try {
+                                       // Create a commit object
+                                       CommitBuilder commitBuilder = new CommitBuilder();
+                                       commitBuilder.setCommitter(committer);
+                                       commitBuilder.setAuthor(committer);
+                                       commitBuilder.setEncoding(Constants.CHARSET);
+                                       if (StringUtils.isEmpty(message)) {
+                                               message = MessageFormat.format("merge {0} into {1}", srcTip.getName(), branchTip.getName());
+                                       }
+                                       commitBuilder.setMessage(message);
+                                       commitBuilder.setParentIds(branchTip.getId(), srcTip.getId());
+                                       commitBuilder.setTreeId(treeId);
+
+                                       // Insert the merge commit into the repository
+                                       ObjectId mergeCommitId = odi.insert(commitBuilder);
+                                       odi.flush();
+
+                                       // set the merge ref to the merge commit
+                                       RevCommit mergeCommit = revWalk.parseCommit(mergeCommitId);
+                                       RefUpdate mergeRefUpdate = repository.updateRef(toBranch);
+                                       mergeRefUpdate.setNewObjectId(mergeCommitId);
+                                       mergeRefUpdate.setRefLogMessage("commit: " + mergeCommit.getShortMessage(), false);
+                                       RefUpdate.Result rc = mergeRefUpdate.forceUpdate();
+                                       switch (rc) {
+                                       case FAST_FORWARD:
+                                               // successful, clean merge
+                                               break;
+                                       default:
+                                               throw new GitBlitException(MessageFormat.format("Unexpected result \"{0}\" when merging commit {1} into {2} in {3}",
+                                                               rc.name(), srcTip.getName(), branchTip.getName(), repository.getDirectory()));
+                                       }
+
+                                       // return the merge commit id
+                                       return new MergeResult(MergeStatus.MERGED, mergeCommitId.getName());
+                               } finally {
+                                       odi.release();
+                               }
+                       }
+               } catch (IOException e) {
+                       LOGGER.error("Failed to merge", e);
+               } finally {
+                       revWalk.release();
+               }
+               return new MergeResult(MergeStatus.FAILED, null);
+       }
 }\r
index fdf68e520b9691cb6bdcbf19da947f83c29a5222..be7148cb60ab178000e6822cbb6866f83133861b 100644 (file)
@@ -274,10 +274,10 @@ public class JsonUtils {
                return builder.create();\r
        }\r
 \r
-       private static class GmtDateTypeAdapter implements JsonSerializer<Date>, JsonDeserializer<Date> {\r
+       public static class GmtDateTypeAdapter implements JsonSerializer<Date>, JsonDeserializer<Date> {\r
                private final DateFormat dateFormat;\r
 \r
-               private GmtDateTypeAdapter() {\r
+               public GmtDateTypeAdapter() {\r
                        dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US);\r
                        dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));\r
                }\r
index 2ce6f56638dff33f4f12be5563a5a7299c08b020..dcd79f16ec7568fb212292a63fe371d48387b8fa 100644 (file)
@@ -132,6 +132,10 @@ public class MarkdownUtils {
                String mentionReplacement = String.format(" **<a href=\"%1s/user/$1\">@$1</a>**", canonicalUrl);\r
                text = text.replaceAll("\\s@([A-Za-z0-9-_]+)", mentionReplacement);\r
 \r
+               // link ticket refs
+               String ticketReplacement = MessageFormat.format("$1[#$2]({0}/tickets?r={1}&h=$2)$3", canonicalUrl, repositoryName);
+               text = text.replaceAll("([\\s,]+)#(\\d+)([\\s,:\\.\\n])", ticketReplacement);
+
                // link commit shas\r
                int shaLen = settings.getInteger(Keys.web.shortCommitIdLength, 6);\r
                String commitPattern = MessageFormat.format("\\s([A-Fa-f0-9]'{'{0}'}')([A-Fa-f0-9]'{'{1}'}')", shaLen, 40 - shaLen);\r
index d19e892a6bb45855b133b36fe55cac77fb8e1815..4c082d0541678137bee15c03dcb52ae19adafcad 100644 (file)
@@ -213,6 +213,22 @@ public class RefLogUtils {
         */
        public static boolean updateRefLog(UserModel user, Repository repository,
                        Collection<ReceiveCommand> commands) {
+
+               // only track branches and tags
+               List<ReceiveCommand> filteredCommands = new ArrayList<ReceiveCommand>();
+               for (ReceiveCommand cmd : commands) {
+                       if (!cmd.getRefName().startsWith(Constants.R_HEADS)
+                                       && !cmd.getRefName().startsWith(Constants.R_TAGS)) {
+                               continue;
+                       }
+                       filteredCommands.add(cmd);
+               }
+
+               if (filteredCommands.isEmpty()) {
+                       // nothing to log
+                       return true;
+               }
+
                RefModel reflogBranch = getRefLogBranch(repository);
                if (reflogBranch == null) {
                        JGitUtils.createOrphanBranch(repository, GB_REFLOG, null);
@@ -443,7 +459,15 @@ public class RefLogUtils {
                        Date date = push.getAuthorIdent().getWhen();
 
                        RefLogEntry log = new RefLogEntry(repositoryName, date, user);
-                       List<PathChangeModel> changedRefs = JGitUtils.getFilesInCommit(repository, push);
+
+                       // only report HEADS and TAGS for now
+                       List<PathChangeModel> changedRefs = new ArrayList<PathChangeModel>();
+                       for (PathChangeModel refChange : JGitUtils.getFilesInCommit(repository, push)) {
+                               if (refChange.path.startsWith(Constants.R_HEADS)
+                                               || refChange.path.startsWith(Constants.R_TAGS)) {
+                                       changedRefs.add(refChange);
+                               }
+                       }
                        if (changedRefs.isEmpty()) {
                                // skip empty commits
                                continue;
@@ -466,12 +490,16 @@ public class RefLogUtils {
                                                // ref deletion
                                                continue;
                                        }
-                                       List<RevCommit> pushedCommits = JGitUtils.getRevLog(repository, oldId, newId);
-                                       for (RevCommit pushedCommit : pushedCommits) {
-                                               RepositoryCommit repoCommit = log.addCommit(change.path, pushedCommit);
-                                               if (repoCommit != null) {
-                                                       repoCommit.setRefs(allRefs.get(pushedCommit.getId()));
+                                       try {
+                                               List<RevCommit> pushedCommits = JGitUtils.getRevLog(repository, oldId, newId);
+                                               for (RevCommit pushedCommit : pushedCommits) {
+                                                       RepositoryCommit repoCommit = log.addCommit(change.path, pushedCommit);
+                                                       if (repoCommit != null) {
+                                                               repoCommit.setRefs(allRefs.get(pushedCommit.getId()));
+                                                       }
                                                }
+                                       } catch (Exception e) {
+
                                        }
                                }
                        }
index ab5ae2a2261b456eecade0956bd407c067c4cd72..445335ffec1e9ef0267560c7bbbd8010779aebf9 100644 (file)
@@ -38,6 +38,7 @@ import com.gitblit.manager.IProjectManager;
 import com.gitblit.manager.IRepositoryManager;
 import com.gitblit.manager.IRuntimeManager;
 import com.gitblit.manager.IUserManager;
+import com.gitblit.tickets.ITicketService;
 import com.gitblit.utils.StringUtils;
 import com.gitblit.wicket.pages.ActivityPage;
 import com.gitblit.wicket.pages.BlamePage;
@@ -49,6 +50,8 @@ import com.gitblit.wicket.pages.CommitPage;
 import com.gitblit.wicket.pages.ComparePage;
 import com.gitblit.wicket.pages.DocPage;
 import com.gitblit.wicket.pages.DocsPage;
+import com.gitblit.wicket.pages.EditTicketPage;
+import com.gitblit.wicket.pages.ExportTicketPage;
 import com.gitblit.wicket.pages.FederationRegistrationPage;
 import com.gitblit.wicket.pages.ForkPage;
 import com.gitblit.wicket.pages.ForksPage;
@@ -59,6 +62,7 @@ import com.gitblit.wicket.pages.LogoutPage;
 import com.gitblit.wicket.pages.LuceneSearchPage;
 import com.gitblit.wicket.pages.MetricsPage;
 import com.gitblit.wicket.pages.MyDashboardPage;
+import com.gitblit.wicket.pages.NewTicketPage;
 import com.gitblit.wicket.pages.OverviewPage;
 import com.gitblit.wicket.pages.PatchPage;
 import com.gitblit.wicket.pages.ProjectPage;
@@ -70,6 +74,7 @@ import com.gitblit.wicket.pages.ReviewProposalPage;
 import com.gitblit.wicket.pages.SummaryPage;
 import com.gitblit.wicket.pages.TagPage;
 import com.gitblit.wicket.pages.TagsPage;
+import com.gitblit.wicket.pages.TicketsPage;
 import com.gitblit.wicket.pages.TreePage;
 import com.gitblit.wicket.pages.UserPage;
 import com.gitblit.wicket.pages.UsersPage;
@@ -168,6 +173,12 @@ public class GitBlitWebApp extends WebApplication {
                mount("/users", UsersPage.class);
                mount("/logout", LogoutPage.class);
 
+               // setup ticket urls
+               mount("/tickets", TicketsPage.class, "r", "h");
+               mount("/tickets/new", NewTicketPage.class, "r");
+               mount("/tickets/edit", EditTicketPage.class, "r", "h");
+               mount("/tickets/export", ExportTicketPage.class, "r", "h");
+
                // setup the markup document urls
                mount("/docs", DocsPage.class, "r");
                mount("/doc", DocPage.class, "r", "h", "f");
@@ -285,6 +296,10 @@ public class GitBlitWebApp extends WebApplication {
                return gitblit;
        }
 
+       public ITicketService tickets() {
+               return gitblit.getTicketService();
+       }
+
        public TimeZone getTimezone() {
                return runtimeManager.getTimezone();
        }
index 8f3a6aafcd59cfcab7b2e9003d4f979a83b32684..86dd585f978170d57d5e0952c7416c95be2e9fd3 100644 (file)
@@ -18,7 +18,7 @@ gb.object = object
 gb.ticketId = ticket id
 gb.ticketAssigned = assigned
 gb.ticketOpenDate = open date
-gb.ticketState = state
+gb.ticketStatus = status
 gb.ticketComments = comments
 gb.view = view
 gb.local = local
@@ -511,4 +511,141 @@ gb.mirrorOf = mirror of {0}
 gb.mirrorWarning = this repository is a mirror and can not receive pushes
 gb.docsWelcome1 = You can use docs to document your repository.
 gb.docsWelcome2 = Commit a README.md or a HOME.md file to get started.
-gb.createReadme = create a README
\ No newline at end of file
+gb.createReadme = create a README
+gb.responsible = responsible
+gb.createdThisTicket = created this ticket
+gb.proposedThisChange = proposed this change
+gb.uploadedPatchsetN = uploaded patchset {0}
+gb.uploadedPatchsetNRevisionN = uploaded patchset {0} revision {1}
+gb.mergedPatchset = merged patchset
+gb.commented = commented
+gb.noDescriptionGiven = no description given
+gb.toBranch = to {0}
+gb.createdBy = created by
+gb.oneParticipant = {0} participant
+gb.nParticipants = {0} participants
+gb.noComments = no comments
+gb.oneComment = {0} comment
+gb.nComments = {0} comments
+gb.oneAttachment  = {0} attachment
+gb.nAttachments = {0} attachments
+gb.milestone = milestone
+gb.compareToMergeBase = compare to merge base
+gb.compareToN = compare to {0}
+gb.open = open
+gb.closed = closed
+gb.merged = merged
+gb.ticketPatchset = ticket {0}, patchset {1}
+gb.patchsetMergeable = This patchset can be automatically merged into {0}.
+gb.patchsetMergeableMore = This patchset may also be merged into {0} from the command line.
+gb.patchsetAlreadyMerged = This patchset has been merged into {0}.
+gb.patchsetNotMergeable = This patchset can not be automatically merged into {0}.
+gb.patchsetNotMergeableMore = This patchset must be rebased or manually merged into {0} to resolve conflicts.
+gb.patchsetNotApproved = This patchset revision has not been approved for merging into {0}.
+gb.patchsetNotApprovedMore = A reviewer must approve this patchset.
+gb.patchsetVetoedMore = A reviewer has vetoed this patchset.
+gb.write = write
+gb.comment = comment
+gb.preview = preview
+gb.leaveComment = leave a comment...
+gb.showHideDetails = show/hide details
+gb.acceptNewPatchsets = accept patchsets
+gb.acceptNewPatchsetsDescription = accept patchsets pushed to this repository
+gb.acceptNewTickets = allow new tickets
+gb.acceptNewTicketsDescription = allow creation of bug, enhancement, task ,etc tickets
+gb.requireApproval = require approvals
+gb.requireApprovalDescription = patchsets must be approved before merge button is enabled
+gb.topic = topic
+gb.proposalTickets = proposed changes
+gb.bugTickets = bugs
+gb.enhancementTickets = enhancements
+gb.taskTickets = tasks
+gb.questionTickets = questions
+gb.requestTickets = enhancements & tasks
+gb.yourCreatedTickets = created by you
+gb.yourWatchedTickets = watched by you
+gb.mentionsMeTickets = mentioning you
+gb.updatedBy = updated by
+gb.sort = sort
+gb.sortNewest = newest
+gb.sortOldest = oldest
+gb.sortMostRecentlyUpdated = recently updated
+gb.sortLeastRecentlyUpdated = least recently updated
+gb.sortMostComments = most comments
+gb.sortLeastComments = least comments
+gb.sortMostPatchsetRevisions = most patchset revisions
+gb.sortLeastPatchsetRevisions = least patchset revisions
+gb.sortMostVotes = most votes
+gb.sortLeastVotes = least votes
+gb.topicsAndLabels = topics & labels
+gb.milestones = milestones
+gb.noMilestoneSelected = no milestone selected
+gb.notSpecified = not specified
+gb.due = due
+gb.queries = queries
+gb.searchTicketsTooltip = search {0} tickets
+gb.searchTickets = search tickets
+gb.new = new
+gb.newTicket = new ticket
+gb.editTicket = edit ticket
+gb.ticketsWelcome = You can use tickets to organize your todo list, discuss bugs, and to collaborate on patchsets.
+gb.createFirstTicket = create your first ticket
+gb.title = title
+gb.changedStatus = changed the status
+gb.discussion = discussion
+gb.updated = updated
+gb.proposePatchset = propose a patchset
+gb.proposePatchsetNote = You are welcome to propose a patchset for this ticket.
+gb.proposeInstructions = To start, craft a patchset and upload it with Git.  Gitblit will link your patchset to this ticket by the id.
+gb.proposeWith = propose a patchset with {0}
+gb.revisionHistory = revision history
+gb.merge = merge
+gb.action = action
+gb.patchset = patchset
+gb.all = all
+gb.mergeBase = merge base
+gb.checkout = checkout
+gb.checkoutViaCommandLine =  Checkout via command line
+gb.checkoutViaCommandLineNote = You can checkout and test these changes locally from your clone of this repository.
+gb.checkoutStep1 = Fetch the current patchset \u2014 run this from your project directory
+gb.checkoutStep2 = Checkout the patchset to a new branch and review
+gb.mergingViaCommandLine = Merging via command line
+gb.mergingViaCommandLineNote = If you do not want to use the merge button or an automatic merge cannot be performed, you can perform a manual merge on the command line.
+gb.mergeStep1 = Check out a new branch to review the changes \u2014 run this from your project directory
+gb.mergeStep2 = Bring in the proposed changes and review
+gb.mergeStep3 = Merge the proposed changes and update the server
+gb.download = download
+gb.ptDescription = the Gitblit patchset tool
+gb.ptCheckout = Fetch & checkout the current patchset to a review branch
+gb.ptMerge = Fetch & merge the current patchset into your local branch
+gb.ptDescription1 = Barnum is a command-line companion for Git that simplifies the syntax for working with Gitblit Tickets and Patchsets.
+gb.ptSimplifiedCollaboration = simplified collaboration syntax
+gb.ptSimplifiedMerge = simplified merge syntax
+gb.ptDescription2 = Barnum requires Python 3 and native Git.  It runs on Windows, Linux, and Mac OS X.
+gb.stepN = Step {0}
+gb.watchers = watchers
+gb.votes = votes
+gb.vote = vote for this {0}
+gb.watch = watch this {0}
+gb.removeVote = remove vote
+gb.stopWatching = stop watching
+gb.watching = watching
+gb.comments = comments
+gb.addComment = add comment
+gb.export = export
+gb.oneCommit = one commit
+gb.nCommits = {0} commits
+gb.addedOneCommit = added 1 commit
+gb.addedNCommits = added {0} commits
+gb.commitsInPatchsetN = commits in patchset {0}
+gb.patchsetN = patchset {0}
+gb.reviewedPatchsetRev = reviewed patchset {0} revision {1}: {2}
+gb.review = review
+gb.reviews = reviews
+gb.veto = veto 
+gb.needsImprovement = needs improvement
+gb.looksGood = looks good
+gb.approve = approve
+gb.hasNotReviewed = has not reviewed
+gb.about = about
+gb.ticketN = ticket #{0}
\ No newline at end of file
index 24ffd813d30edd56317cc9f9ab09843f39693d7d..3e3de535c87ddf44a29202b74aa7267af887bb7e 100644 (file)
@@ -15,6 +15,8 @@
  */\r
 package com.gitblit.wicket.pages;\r
 \r
+import java.io.IOException;\r
+import java.io.InputStream;\r
 import java.text.MessageFormat;\r
 import java.util.ArrayList;\r
 import java.util.Calendar;\r
@@ -31,6 +33,7 @@ import java.util.regex.Pattern;
 \r
 import javax.servlet.http.HttpServletRequest;\r
 \r
+import org.apache.commons.io.IOUtils;\r
 import org.apache.wicket.Application;\r
 import org.apache.wicket.Page;\r
 import org.apache.wicket.PageParameters;\r
@@ -460,4 +463,26 @@ public abstract class BasePage extends SessionPage {
                }\r
                error(message, true);\r
        }\r
+\r
+       protected String readResource(String resource) {\r
+               StringBuilder sb = new StringBuilder();\r
+               InputStream is = null;\r
+               try {\r
+                       is = getClass().getResourceAsStream(resource);\r
+                       List<String> lines = IOUtils.readLines(is);\r
+                       for (String line : lines) {\r
+                               sb.append(line).append('\n');\r
+                       }\r
+               } catch (IOException e) {\r
+\r
+               } finally {\r
+                       if (is != null) {\r
+                               try {\r
+                                       is.close();\r
+                               } catch (IOException e) {\r
+                               }\r
+                       }\r
+               }\r
+               return sb.toString();\r
+       }\r
 }\r
index 781cf29e744445adf91991435c6b82b25845e993..da19ca0fdd4e98de1316f015d0c69c53facc04d5 100644 (file)
                                <tr><th><wicket:message key="gb.gcPeriod"></wicket:message></th><td class="edit"><select class="span2" wicket:id="gcPeriod" tabindex="5" /> &nbsp;<span class="help-inline"><wicket:message key="gb.gcPeriodDescription"></wicket:message></span></td></tr>\r
                                <tr><th><wicket:message key="gb.gcThreshold"></wicket:message></th><td class="edit"><input class="span1" type="text" wicket:id="gcThreshold" tabindex="6" /> &nbsp;<span class="help-inline"><wicket:message key="gb.gcThresholdDescription"></wicket:message></span></td></tr>\r
                                <tr><th colspan="2"><hr/></th></tr>\r
-                               <tr><th><wicket:message key="gb.enableIncrementalPushTags"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="useIncrementalPushTags" tabindex="7" /> &nbsp;<span class="help-inline"><wicket:message key="gb.useIncrementalPushTagsDescription"></wicket:message></span></label></td></tr>\r
-                               <tr><th><wicket:message key="gb.showRemoteBranches"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="showRemoteBranches" tabindex="8" /> &nbsp;<span class="help-inline"><wicket:message key="gb.showRemoteBranchesDescription"></wicket:message></span></label></td></tr>\r
-                               <tr><th><wicket:message key="gb.skipSizeCalculation"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="skipSizeCalculation" tabindex="9" /> &nbsp;<span class="help-inline"><wicket:message key="gb.skipSizeCalculationDescription"></wicket:message></span></label></td></tr>\r
-                               <tr><th><wicket:message key="gb.skipSummaryMetrics"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="skipSummaryMetrics" tabindex="10" /> &nbsp;<span class="help-inline"><wicket:message key="gb.skipSummaryMetricsDescription"></wicket:message></span></label></td></tr>\r
-                               <tr><th><wicket:message key="gb.maxActivityCommits"></wicket:message></th><td class="edit"><select class="span2" wicket:id="maxActivityCommits" tabindex="11" /> &nbsp;<span class="help-inline"><wicket:message key="gb.maxActivityCommitsDescription"></wicket:message></span></td></tr>\r
-                               <tr><th><wicket:message key="gb.metricAuthorExclusions"></wicket:message></th><td class="edit"><input class="span8" type="text" wicket:id="metricAuthorExclusions" size="40" tabindex="12" /></td></tr>\r
-                               <tr><th><wicket:message key="gb.commitMessageRenderer"></wicket:message></th><td class="edit"><select class="span2" wicket:id="commitMessageRenderer" tabindex="13" /></td></tr>\r
+                               <tr><th><wicket:message key="gb.acceptNewTickets"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="acceptNewTickets" tabindex="7" /> &nbsp;<span class="help-inline"><wicket:message key="gb.acceptNewTicketsDescription"></wicket:message></span></label></td></tr>\r
+                               <tr><th><wicket:message key="gb.acceptNewPatchsets"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="acceptNewPatchsets" tabindex="8" /> &nbsp;<span class="help-inline"><wicket:message key="gb.acceptNewPatchsetsDescription"></wicket:message></span></label></td></tr>\r
+                               <tr><th><wicket:message key="gb.requireApproval"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="requireApproval" tabindex="9" /> &nbsp;<span class="help-inline"><wicket:message key="gb.requireApprovalDescription"></wicket:message></span></label></td></tr>\r
+                               <tr><th><wicket:message key="gb.enableIncrementalPushTags"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="useIncrementalPushTags" tabindex="10" /> &nbsp;<span class="help-inline"><wicket:message key="gb.useIncrementalPushTagsDescription"></wicket:message></span></label></td></tr>\r
+                               <tr><th><wicket:message key="gb.showRemoteBranches"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="showRemoteBranches" tabindex="11" /> &nbsp;<span class="help-inline"><wicket:message key="gb.showRemoteBranchesDescription"></wicket:message></span></label></td></tr>\r
+                               <tr><th><wicket:message key="gb.skipSizeCalculation"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="skipSizeCalculation" tabindex="12" /> &nbsp;<span class="help-inline"><wicket:message key="gb.skipSizeCalculationDescription"></wicket:message></span></label></td></tr>\r
+                               <tr><th><wicket:message key="gb.skipSummaryMetrics"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="skipSummaryMetrics" tabindex="13" /> &nbsp;<span class="help-inline"><wicket:message key="gb.skipSummaryMetricsDescription"></wicket:message></span></label></td></tr>\r
+                               <tr><th><wicket:message key="gb.maxActivityCommits"></wicket:message></th><td class="edit"><select class="span2" wicket:id="maxActivityCommits" tabindex="14" /> &nbsp;<span class="help-inline"><wicket:message key="gb.maxActivityCommitsDescription"></wicket:message></span></td></tr>\r
+                               <tr><th><wicket:message key="gb.metricAuthorExclusions"></wicket:message></th><td class="edit"><input class="span8" type="text" wicket:id="metricAuthorExclusions" size="40" tabindex="15" /></td></tr>\r
+                               <tr><th><wicket:message key="gb.commitMessageRenderer"></wicket:message></th><td class="edit"><select class="span2" wicket:id="commitMessageRenderer" tabindex="16" /></td></tr>\r
                                <tr><th colspan="2"><hr/></th></tr>\r
-                               <tr><th><wicket:message key="gb.mailingLists"></wicket:message></th><td class="edit"><input class="span8" type="text" wicket:id="mailingLists" size="40" tabindex="14" /></td></tr>\r
+                               <tr><th><wicket:message key="gb.mailingLists"></wicket:message></th><td class="edit"><input class="span8" type="text" wicket:id="mailingLists" size="40" tabindex="17" /></td></tr>\r
                        </tbody>\r
                </table>\r
                </div>\r
                <div class="tab-pane" id="permissions">\r
                        <table class="plain">\r
                                <tbody class="settings">\r
-                                       <tr><th><wicket:message key="gb.owners"></wicket:message></th><td class="edit"><span wicket:id="owners" tabindex="15" /> </td></tr>\r
+                                       <tr><th><wicket:message key="gb.owners"></wicket:message></th><td class="edit"><span wicket:id="owners" tabindex="18" /> </td></tr>\r
                                        <tr><th colspan="2"><hr/></th></tr>\r
-                                       <tr><th><wicket:message key="gb.accessRestriction"></wicket:message></th><td class="edit"><select class="span4" wicket:id="accessRestriction" tabindex="16" /></td></tr>\r
+                                       <tr><th><wicket:message key="gb.accessRestriction"></wicket:message></th><td class="edit"><select class="span4" wicket:id="accessRestriction" tabindex="19" /></td></tr>\r
                                        <tr><th colspan="2"><hr/></th></tr>\r
                                        <tr><th><wicket:message key="gb.authorizationControl"></wicket:message></th><td style="padding:2px;"><span class="authorizationControl" wicket:id="authorizationControl"></span></td></tr>\r
                                        <tr><th colspan="2"><hr/></th></tr>\r
-                                       <tr><th><wicket:message key="gb.isFrozen"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="isFrozen" tabindex="17" /> &nbsp;<span class="help-inline"><wicket:message key="gb.isFrozenDescription"></wicket:message></span></label></td></tr>\r
-                                       <tr><th><wicket:message key="gb.allowForks"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="allowForks" tabindex="18" /> &nbsp;<span class="help-inline"><wicket:message key="gb.allowForksDescription"></wicket:message></span></label></td></tr>\r
-                                       <tr><th><wicket:message key="gb.verifyCommitter"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="verifyCommitter" tabindex="19" /> &nbsp;<span class="help-inline"><wicket:message key="gb.verifyCommitterDescription"></wicket:message></span><br/><span class="help-inline" style="padding-left:10px;"><wicket:message key="gb.verifyCommitterNote"></wicket:message></span></label></td></tr>\r
+                                       <tr><th><wicket:message key="gb.isFrozen"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="isFrozen" tabindex="20" /> &nbsp;<span class="help-inline"><wicket:message key="gb.isFrozenDescription"></wicket:message></span></label></td></tr>\r
+                                       <tr><th><wicket:message key="gb.allowForks"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="allowForks" tabindex="21" /> &nbsp;<span class="help-inline"><wicket:message key="gb.allowForksDescription"></wicket:message></span></label></td></tr>\r
+                                       <tr><th><wicket:message key="gb.verifyCommitter"></wicket:message></th><td class="edit"><label class="checkbox"><input type="checkbox" wicket:id="verifyCommitter" tabindex="22" /> &nbsp;<span class="help-inline"><wicket:message key="gb.verifyCommitterDescription"></wicket:message></span><br/><span class="help-inline" style="padding-left:10px;"><wicket:message key="gb.verifyCommitterNote"></wicket:message></span></label></td></tr>\r
                                        <tr><th colspan="2"><hr/></th></tr>\r
                                        <tr><th><wicket:message key="gb.userPermissions"></wicket:message></th><td style="padding:2px;"><span wicket:id="users"></span></td></tr>\r
                                        <tr><th colspan="2"><hr/></th></tr>\r
@@ -72,7 +75,7 @@
                <div class="tab-pane" id="federation">\r
                        <table class="plain">\r
                                <tbody class="settings">\r
-                                       <tr><th><wicket:message key="gb.federationStrategy"></wicket:message></th><td class="edit"><select class="span4" wicket:id="federationStrategy" tabindex="20" /></td></tr>\r
+                                       <tr><th><wicket:message key="gb.federationStrategy"></wicket:message></th><td class="edit"><select class="span4" wicket:id="federationStrategy" tabindex="23" /></td></tr>\r
                                        <tr><th><wicket:message key="gb.federationSets"></wicket:message></th><td style="padding:2px;"><span wicket:id="federationSets"></span></td></tr>\r
                                </tbody>\r
                        </table>\r
index c4f480bbebd9d9211c61eb0241cc069bddb93bbb..3a5f122fedbc3edaf0142047a81f88ab483f763d 100644 (file)
@@ -410,12 +410,12 @@ public class EditRepositoryPage extends RootSubPage {
                                        }\r
 \r
                                        // save the repository\r
-                                       app().repositories().updateRepositoryModel(oldName, repositoryModel, isCreate);\r
+                                       app().gitblit().updateRepositoryModel(oldName, repositoryModel, isCreate);\r
 \r
                                        // repository access permissions\r
                                        if (repositoryModel.accessRestriction.exceeds(AccessRestrictionType.NONE)) {\r
-                                               app().repositories().setUserAccessPermissions(repositoryModel, repositoryUsers);\r
-                                               app().repositories().setTeamAccessPermissions(repositoryModel, repositoryTeams);\r
+                                               app().gitblit().setUserAccessPermissions(repositoryModel, repositoryUsers);\r
+                                               app().gitblit().setTeamAccessPermissions(repositoryModel, repositoryTeams);\r
                                        }\r
                                } catch (GitBlitException e) {\r
                                        error(e.getMessage());\r
@@ -466,11 +466,14 @@ public class EditRepositoryPage extends RootSubPage {
                }\r
                form.add(new DropDownChoice<FederationStrategy>("federationStrategy", federationStrategies,\r
                                new FederationTypeRenderer()));\r
+               form.add(new CheckBox("acceptNewPatchsets"));\r
+               form.add(new CheckBox("acceptNewTickets"));
+               form.add(new CheckBox("requireApproval"));\r
                form.add(new CheckBox("useIncrementalPushTags"));\r
                form.add(new CheckBox("showRemoteBranches"));\r
                form.add(new CheckBox("skipSizeCalculation"));\r
                form.add(new CheckBox("skipSummaryMetrics"));\r
-               List<Integer> maxActivityCommits  = Arrays.asList(-1, 0, 25, 50, 75, 100, 150, 200, 250, 500 );\r
+               List<Integer> maxActivityCommits  = Arrays.asList(-1, 0, 25, 50, 75, 100, 150, 200, 250, 500);\r
                form.add(new DropDownChoice<Integer>("maxActivityCommits", maxActivityCommits, new MaxActivityCommitsRenderer()));\r
 \r
                metricAuthorExclusions = new Model<String>(ArrayUtils.isEmpty(repositoryModel.metricAuthorExclusions) ? ""\r
diff --git a/src/main/java/com/gitblit/wicket/pages/EditTicketPage.html b/src/main/java/com/gitblit/wicket/pages/EditTicketPage.html
new file mode 100644 (file)
index 0000000..5d8f682
--- /dev/null
@@ -0,0 +1,66 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">\r
+<html xmlns="http://www.w3.org/1999/xhtml"  \r
+      xmlns:wicket="http://wicket.apache.org/dtds.data/wicket-xhtml1.3-strict.dtd"  \r
+      xml:lang="en"  \r
+      lang="en"> \r
+\r
+<wicket:extend>\r
+<body onload="document.getElementById('title').focus();">\r
+       \r
+<div class="container">\r
+       <!-- page header -->\r
+       <div class="title" style="font-size: 22px; color: rgb(0, 32, 96);padding: 3px 0px 7px;">\r
+               <span class="project"><wicket:message key="gb.editTicket"></wicket:message></span>\r
+       </div>\r
+\r
+       <form style="padding-top:5px;" wicket:id="editForm">\r
+       <div class="row">\r
+       <div class="span12">\r
+               <!-- Edit Ticket Table -->\r
+               <table class="ticket">\r
+                       <tr><th><wicket:message key="gb.title"></wicket:message></th><td class="edit"><input class="input-xxlarge" type="text" wicket:id="title" id="title"></input></td></tr>\r
+                       <tr><th><wicket:message key="gb.topic"></wicket:message></th><td class="edit"><input class="input-large" type="text" wicket:id="topic"></input></td></tr>\r
+                       <tr><th style="vertical-align: top;"><wicket:message key="gb.description"></wicket:message></th><td class="edit">\r
+                               <div style="background-color:#fbfbfb;border:1px solid #ccc;">\r
+                               <ul class="nav nav-pills" style="margin: 2px 5px !important">\r
+                                       <li class="active"><a tabindex="-1" href="#edit" data-toggle="tab"><wicket:message key="gb.edit">[edit]</wicket:message></a></li>\r
+                                       <li><a tabindex="-1" href="#preview" data-toggle="tab"><wicket:message key="gb.preview">[preview]</wicket:message></a></li>\r
+                               </ul>\r
+                               <div class="tab-content">\r
+                                       <div class="tab-pane active" id="edit">                                         \r
+                                               <textarea class="input-xxlarge ticket-text-editor" wicket:id="description"></textarea>\r
+                                       </div>\r
+                                       <div class="tab-pane" id="preview">\r
+                                               <div class="preview ticket-text-editor">\r
+                                                       <div class="markdown input-xxlarge" wicket:id="descriptionPreview"></div>\r
+                                               </div>\r
+                                       </div>\r
+                               </div>\r
+                               </div>\r
+                       </td></tr>\r
+                       <tr><th><wicket:message key="gb.type"></wicket:message><span style="color:red;">*</span></th><td class="edit"><select class="input-large" wicket:id="type"></select></td></tr>\r
+                       <tr wicket:id="responsible"></tr>\r
+                       <tr wicket:id="milestone"></tr>\r
+               </table>\r
+       </div>\r
+       </div>  \r
+\r
+       <div class="row">\r
+       <div class="span12">\r
+               <div class="form-actions"><input class="btn btn-appmenu" type="submit" value="save" wicket:message="value:gb.save" wicket:id="update" /> &nbsp; <input class="btn" type="submit" value="Cancel" wicket:message="value:gb.cancel" wicket:id="cancel" /></div>\r
+       </div>\r
+       </div>\r
+       </form>\r
+</div>\r
+</body>\r
+\r
+<wicket:fragment wicket:id="responsibleFragment">\r
+       <th><wicket:message key="gb.responsible"></wicket:message></th><td class="edit"><select class="input-large" wicket:id="responsible"></select></td>\r
+</wicket:fragment>\r
+\r
+<wicket:fragment wicket:id="milestoneFragment">\r
+       <th><wicket:message key="gb.milestone"></wicket:message></th><td class="edit"><select class="input-large" wicket:id="milestone"></select></td>\r
+</wicket:fragment>\r
+\r
+</wicket:extend>\r
+</html>
\ No newline at end of file
diff --git a/src/main/java/com/gitblit/wicket/pages/EditTicketPage.java b/src/main/java/com/gitblit/wicket/pages/EditTicketPage.java
new file mode 100644 (file)
index 0000000..5446dde
--- /dev/null
@@ -0,0 +1,290 @@
+/*\r
+ * Copyright 2014 gitblit.com.\r
+ *\r
+ * Licensed under the Apache License, Version 2.0 (the "License");\r
+ * you may not use this file except in compliance with the License.\r
+ * You may obtain a copy of the License at\r
+ *\r
+ *     http://www.apache.org/licenses/LICENSE-2.0\r
+ *\r
+ * Unless required by applicable law or agreed to in writing, software\r
+ * distributed under the License is distributed on an "AS IS" BASIS,\r
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r
+ * See the License for the specific language governing permissions and\r
+ * limitations under the License.\r
+ */\r
+package com.gitblit.wicket.pages;\r
+\r
+import java.util.ArrayList;\r
+import java.util.Arrays;\r
+import java.util.Collections;\r
+import java.util.List;\r
+import java.util.Set;\r
+import java.util.TreeSet;\r
+\r
+import org.apache.wicket.PageParameters;\r
+import org.apache.wicket.markup.html.basic.Label;\r
+import org.apache.wicket.markup.html.form.Button;\r
+import org.apache.wicket.markup.html.form.DropDownChoice;\r
+import org.apache.wicket.markup.html.form.Form;\r
+import org.apache.wicket.markup.html.form.TextField;\r
+import org.apache.wicket.markup.html.panel.Fragment;\r
+import org.apache.wicket.model.IModel;\r
+import org.apache.wicket.model.Model;\r
+\r
+import com.gitblit.Constants.AccessPermission;\r
+import com.gitblit.models.RegistrantAccessPermission;\r
+import com.gitblit.models.TicketModel;\r
+import com.gitblit.models.TicketModel.Change;\r
+import com.gitblit.models.TicketModel.Field;\r
+import com.gitblit.models.TicketModel.Status;\r
+import com.gitblit.models.TicketModel.Type;\r
+import com.gitblit.models.UserModel;\r
+import com.gitblit.tickets.TicketMilestone;\r
+import com.gitblit.tickets.TicketNotifier;\r
+import com.gitblit.tickets.TicketResponsible;\r
+import com.gitblit.utils.StringUtils;\r
+import com.gitblit.wicket.GitBlitWebSession;\r
+import com.gitblit.wicket.WicketUtils;\r
+import com.gitblit.wicket.panels.MarkdownTextArea;\r
+\r
+/**\r
+ * Page for editing a ticket.\r
+ *\r
+ * @author James Moger\r
+ *\r
+ */\r
+public class EditTicketPage extends RepositoryPage {\r
+\r
+       static final String NIL = "<nil>";\r
+\r
+       static final String ESC_NIL = StringUtils.escapeForHtml(NIL,  false);\r
+\r
+       private IModel<TicketModel.Type> typeModel;\r
+\r
+       private IModel<String> titleModel;\r
+\r
+       private MarkdownTextArea descriptionEditor;\r
+\r
+       private IModel<String> topicModel;\r
+\r
+       private IModel<TicketResponsible> responsibleModel;\r
+\r
+       private IModel<TicketMilestone> milestoneModel;\r
+\r
+       private Label descriptionPreview;\r
+\r
+       public EditTicketPage(PageParameters params) {\r
+               super(params);\r
+\r
+               UserModel currentUser = GitBlitWebSession.get().getUser();\r
+               if (currentUser == null) {\r
+                       currentUser = UserModel.ANONYMOUS;\r
+               }\r
+\r
+               if (!currentUser.isAuthenticated || !app().tickets().isAcceptingTicketUpdates(getRepositoryModel())) {\r
+                       // tickets prohibited\r
+                       setResponsePage(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));\r
+               }\r
+\r
+               long ticketId = 0L;\r
+               try {\r
+                       String h = WicketUtils.getObject(params);\r
+                       ticketId = Long.parseLong(h);\r
+               } catch (Exception e) {\r
+                       setResponsePage(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));\r
+               }\r
+\r
+               TicketModel ticket = app().tickets().getTicket(getRepositoryModel(), ticketId);\r
+               if (ticket == null) {\r
+                       setResponsePage(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));\r
+               }\r
+\r
+               typeModel = Model.of(ticket.type);\r
+               titleModel = Model.of(ticket.title);\r
+               topicModel = Model.of(ticket.topic == null ? "" : ticket.topic);\r
+               responsibleModel = Model.of();\r
+               milestoneModel = Model.of();\r
+\r
+               setStatelessHint(false);\r
+               setOutputMarkupId(true);\r
+\r
+               Form<Void> form = new Form<Void>("editForm") {\r
+\r
+                       private static final long serialVersionUID = 1L;\r
+\r
+                       @Override\r
+                       protected void onSubmit() {\r
+                               long ticketId = 0L;\r
+                               try {\r
+                                       String h = WicketUtils.getObject(getPageParameters());\r
+                                       ticketId = Long.parseLong(h);\r
+                               } catch (Exception e) {\r
+                                       setResponsePage(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));\r
+                               }\r
+\r
+                               TicketModel ticket = app().tickets().getTicket(getRepositoryModel(), ticketId);\r
+\r
+                               String createdBy = GitBlitWebSession.get().getUsername();\r
+                               Change change = new Change(createdBy);\r
+\r
+                               String title = titleModel.getObject();\r
+                               if (!ticket.title.equals(title)) {\r
+                                       // title change\r
+                                       change.setField(Field.title, title);\r
+                               }\r
+\r
+                               String description = descriptionEditor.getText();\r
+                               if (!ticket.body.equals(description)) {\r
+                                       // description change\r
+                                       change.setField(Field.body, description);\r
+                               }\r
+\r
+                               Type type = typeModel.getObject();\r
+                               if (!ticket.type.equals(type)) {\r
+                                       // type change\r
+                                       change.setField(Field.type, type);\r
+                               }\r
+\r
+                               String topic = topicModel.getObject();\r
+                               if ((StringUtils.isEmpty(ticket.topic) && !StringUtils.isEmpty(topic))\r
+                                               || (!StringUtils.isEmpty(topic) && !topic.equals(ticket.topic))) {\r
+                                       // topic change\r
+                                       change.setField(Field.topic, topic);\r
+                               }\r
+\r
+                               TicketResponsible responsible = responsibleModel == null ? null : responsibleModel.getObject();\r
+                               if (responsible != null && !responsible.username.equals(ticket.responsible)) {\r
+                                       // responsible change\r
+                                       change.setField(Field.responsible, responsible.username);\r
+                                       if (!StringUtils.isEmpty(responsible.username)) {\r
+                                               if (!ticket.isWatching(responsible.username)) {\r
+                                                       change.watch(responsible.username);\r
+                                               }\r
+                                       }\r
+                               }\r
+\r
+                               TicketMilestone milestone = milestoneModel == null ? null : milestoneModel.getObject();\r
+                               if (milestone != null && !milestone.name.equals(ticket.milestone)) {\r
+                                       // milestone change\r
+                                       if (NIL.equals(milestone.name)) {\r
+                                               change.setField(Field.milestone, "");\r
+                                       } else {\r
+                                               change.setField(Field.milestone, milestone.name);\r
+                                       }\r
+                               }\r
+\r
+                               if (change.hasFieldChanges()) {\r
+                                       if (!ticket.isWatching(createdBy)) {\r
+                                               change.watch(createdBy);\r
+                                       }\r
+                                       ticket = app().tickets().updateTicket(getRepositoryModel(), ticket.number, change);\r
+                                       if (ticket != null) {\r
+                                               TicketNotifier notifier = app().tickets().createNotifier();\r
+                                               notifier.sendMailing(ticket);\r
+                                               setResponsePage(TicketsPage.class, WicketUtils.newObjectParameter(getRepositoryModel().name, "" + ticket.number));\r
+                                       } else {\r
+                                               // TODO error\r
+                                       }\r
+                               } else {\r
+                                       // nothing to change?!\r
+                                       setResponsePage(TicketsPage.class, WicketUtils.newObjectParameter(getRepositoryModel().name, "" + ticket.number));\r
+                               }\r
+                       }\r
+               };\r
+               add(form);\r
+\r
+               List<Type> typeChoices;\r
+               if (ticket.isProposal()) {\r
+                       typeChoices = Arrays.asList(Type.Proposal);\r
+               } else {\r
+                       typeChoices = Arrays.asList(TicketModel.Type.choices());\r
+               }\r
+               form.add(new DropDownChoice<TicketModel.Type>("type", typeModel, typeChoices));\r
+               form.add(new TextField<String>("title", titleModel));\r
+               form.add(new TextField<String>("topic", topicModel));\r
+\r
+               final IModel<String> markdownPreviewModel = new Model<String>();\r
+               descriptionPreview = new Label("descriptionPreview", markdownPreviewModel);\r
+               descriptionPreview.setEscapeModelStrings(false);\r
+               descriptionPreview.setOutputMarkupId(true);\r
+               form.add(descriptionPreview);\r
+\r
+               descriptionEditor = new MarkdownTextArea("description", markdownPreviewModel, descriptionPreview);\r
+               descriptionEditor.setRepository(repositoryName);\r
+               descriptionEditor.setText(ticket.body);\r
+               form.add(descriptionEditor);\r
+\r
+               if (currentUser != null && currentUser.isAuthenticated && currentUser.canPush(getRepositoryModel())) {\r
+                       // responsible\r
+                       Set<String> userlist = new TreeSet<String>(ticket.getParticipants());\r
+\r
+                       for (RegistrantAccessPermission rp : app().repositories().getUserAccessPermissions(getRepositoryModel())) {\r
+                               if (rp.permission.atLeast(AccessPermission.PUSH) && !rp.isTeam()) {\r
+                                       userlist.add(rp.registrant);\r
+                               }\r
+                       }\r
+\r
+                       List<TicketResponsible> responsibles = new ArrayList<TicketResponsible>();\r
+                       for (String username : userlist) {\r
+                               UserModel user = app().users().getUserModel(username);\r
+                               if (user != null) {\r
+                                       TicketResponsible responsible = new TicketResponsible(user);\r
+                                       responsibles.add(responsible);\r
+                                       if (user.username.equals(ticket.responsible)) {\r
+                                               responsibleModel.setObject(responsible);\r
+                                       }\r
+                               }\r
+                       }\r
+                       Collections.sort(responsibles);\r
+                       responsibles.add(new TicketResponsible(NIL, "", ""));\r
+                       Fragment responsible = new Fragment("responsible", "responsibleFragment", this);\r
+                       responsible.add(new DropDownChoice<TicketResponsible>("responsible", responsibleModel, responsibles));\r
+                       form.add(responsible.setVisible(!responsibles.isEmpty()));\r
+\r
+                       // milestone\r
+                       List<TicketMilestone> milestones = app().tickets().getMilestones(getRepositoryModel(), Status.Open);\r
+                       for (TicketMilestone milestone : milestones) {\r
+                               if (milestone.name.equals(ticket.milestone)) {\r
+                                       milestoneModel.setObject(milestone);\r
+                                       break;\r
+                               }\r
+                       }\r
+                       if (!milestones.isEmpty()) {\r
+                               milestones.add(new TicketMilestone(NIL));\r
+                       }\r
+\r
+                       Fragment milestone = new Fragment("milestone", "milestoneFragment", this);\r
+\r
+                       milestone.add(new DropDownChoice<TicketMilestone>("milestone", milestoneModel, milestones));\r
+                       form.add(milestone.setVisible(!milestones.isEmpty()));\r
+               } else {\r
+                       // user does not have permission to assign milestone or responsible\r
+                       form.add(new Label("responsible").setVisible(false));\r
+                       form.add(new Label("milestone").setVisible(false));\r
+               }\r
+\r
+               form.add(new Button("update"));\r
+               Button cancel = new Button("cancel") {\r
+                       private static final long serialVersionUID = 1L;\r
+\r
+                       @Override\r
+                       public void onSubmit() {\r
+                               setResponsePage(TicketsPage.class, getPageParameters());\r
+                       }\r
+               };\r
+               cancel.setDefaultFormProcessing(false);\r
+               form.add(cancel);\r
+\r
+       }\r
+\r
+       @Override\r
+       protected String getPageName() {\r
+               return getString("gb.editTicket");\r
+       }\r
+\r
+       @Override\r
+       protected Class<? extends BasePage> getRepoNavPageClass() {\r
+               return TicketsPage.class;\r
+       }\r
+}\r
diff --git a/src/main/java/com/gitblit/wicket/pages/ExportTicketPage.java b/src/main/java/com/gitblit/wicket/pages/ExportTicketPage.java
new file mode 100644 (file)
index 0000000..57f61f7
--- /dev/null
@@ -0,0 +1,82 @@
+/*\r
+ * Copyright 2013 gitblit.com.\r
+ *\r
+ * Licensed under the Apache License, Version 2.0 (the "License");\r
+ * you may not use this file except in compliance with the License.\r
+ * You may obtain a copy of the License at\r
+ *\r
+ *     http://www.apache.org/licenses/LICENSE-2.0\r
+ *\r
+ * Unless required by applicable law or agreed to in writing, software\r
+ * distributed under the License is distributed on an "AS IS" BASIS,\r
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r
+ * See the License for the specific language governing permissions and\r
+ * limitations under the License.\r
+ */\r
+package com.gitblit.wicket.pages;\r
+\r
+import org.apache.wicket.IRequestTarget;\r
+import org.apache.wicket.PageParameters;\r
+import org.apache.wicket.RequestCycle;\r
+import org.apache.wicket.protocol.http.WebResponse;\r
+import org.slf4j.Logger;\r
+import org.slf4j.LoggerFactory;\r
+\r
+import com.gitblit.models.RepositoryModel;\r
+import com.gitblit.models.TicketModel;\r
+import com.gitblit.tickets.TicketSerializer;\r
+import com.gitblit.utils.StringUtils;\r
+import com.gitblit.wicket.WicketUtils;\r
+\r
+public class ExportTicketPage extends SessionPage {\r
+\r
+       private final Logger logger = LoggerFactory.getLogger(getClass().getSimpleName());\r
+\r
+       String contentType;\r
+\r
+       public ExportTicketPage(final PageParameters params) {\r
+               super(params);\r
+\r
+               if (!params.containsKey("r")) {\r
+                       error(getString("gb.repositoryNotSpecified"));\r
+                       redirectToInterceptPage(new RepositoriesPage());\r
+               }\r
+\r
+               getRequestCycle().setRequestTarget(new IRequestTarget() {\r
+                       @Override\r
+                       public void detach(RequestCycle requestCycle) {\r
+                       }\r
+\r
+                       @Override\r
+                       public void respond(RequestCycle requestCycle) {\r
+                               WebResponse response = (WebResponse) requestCycle.getResponse();\r
+\r
+                               final String repositoryName = WicketUtils.getRepositoryName(params);\r
+                               RepositoryModel repository = app().repositories().getRepositoryModel(repositoryName);\r
+                               String objectId = WicketUtils.getObject(params).toLowerCase();\r
+                               if (objectId.endsWith(".json")) {\r
+                                       objectId = objectId.substring(0, objectId.length() - ".json".length());\r
+                               }\r
+                               long id = Long.parseLong(objectId);\r
+                               TicketModel ticket = app().tickets().getTicket(repository, id);\r
+\r
+                               String content = TicketSerializer.serialize(ticket);\r
+                               contentType = "application/json; charset=UTF-8";\r
+                               response.setContentType(contentType);\r
+                               try {\r
+                                       response.getOutputStream().write(content.getBytes("UTF-8"));\r
+                               } catch (Exception e) {\r
+                                       logger.error("Failed to write text response", e);\r
+                               }\r
+                       }\r
+               });\r
+       }\r
+\r
+       @Override\r
+       protected void setHeaders(WebResponse response) {\r
+               super.setHeaders(response);\r
+               if (!StringUtils.isEmpty(contentType)) {\r
+                       response.setContentType(contentType);\r
+               }\r
+       }\r
+}\r
diff --git a/src/main/java/com/gitblit/wicket/pages/NewTicketPage.html b/src/main/java/com/gitblit/wicket/pages/NewTicketPage.html
new file mode 100644 (file)
index 0000000..71570df
--- /dev/null
@@ -0,0 +1,66 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">\r
+<html xmlns="http://www.w3.org/1999/xhtml"  \r
+      xmlns:wicket="http://wicket.apache.org/dtds.data/wicket-xhtml1.3-strict.dtd"  \r
+      xml:lang="en"  \r
+      lang="en"> \r
+\r
+<wicket:extend>\r
+<body onload="document.getElementById('title').focus();">\r
+       \r
+<div class="container">\r
+       <!-- page header -->\r
+       <div class="title" style="font-size: 22px; color: rgb(0, 32, 96);padding: 3px 0px 7px;">\r
+               <span class="project"><wicket:message key="gb.newTicket"></wicket:message></span>\r
+       </div>\r
+\r
+       <form style="padding-top:5px;" wicket:id="editForm">\r
+       <div class="row">\r
+       <div class="span12">\r
+               <!-- New Ticket Table -->\r
+               <table class="ticket">\r
+                       <tr><th><wicket:message key="gb.title"></wicket:message></th><td class="edit"><input class="input-xxlarge" type="text" wicket:id="title" id="title"></input></td></tr>\r
+                       <tr><th><wicket:message key="gb.topic"></wicket:message></th><td class="edit"><input class="input-large" type="text" wicket:id="topic"></input></td></tr>\r
+                       <tr><th style="vertical-align: top;"><wicket:message key="gb.description"></wicket:message></th><td class="edit">\r
+                               <div style="background-color:#fbfbfb;border:1px solid #ccc;">\r
+                               <ul class="nav nav-pills" style="margin: 2px 5px !important">\r
+                                       <li class="active"><a tabindex="-1" href="#edit" data-toggle="tab"><wicket:message key="gb.edit">[edit]</wicket:message></a></li>\r
+                                       <li><a tabindex="-1" href="#preview" data-toggle="tab"><wicket:message key="gb.preview">[preview]</wicket:message></a></li>\r
+                               </ul>\r
+                               <div class="tab-content">\r
+                                       <div class="tab-pane active" id="edit">                                         \r
+                                               <textarea class="input-xxlarge ticket-text-editor" wicket:id="description"></textarea>\r
+                                       </div>\r
+                                       <div class="tab-pane" id="preview">\r
+                                               <div class="preview ticket-text-editor">\r
+                                                       <div class="markdown input-xxlarge" wicket:id="descriptionPreview"></div>\r
+                                               </div>\r
+                                       </div>\r
+                               </div>                                  \r
+                               </div>\r
+                       </td></tr>\r
+                       <tr><th><wicket:message key="gb.type"></wicket:message><span style="color:red;">*</span></th><td class="edit"><select class="input-large" wicket:id="type"></select></td></tr>\r
+                       <tr wicket:id="responsible"></tr>\r
+                       <tr wicket:id="milestone"></tr>\r
+               </table>\r
+       </div>\r
+       </div>  \r
+\r
+       <div class="row">\r
+       <div class="span12">\r
+               <div class="form-actions"><input class="btn btn-appmenu" type="submit" value="Create" wicket:message="value:gb.create" wicket:id="create" /> &nbsp; <input class="btn" type="submit" value="Cancel" wicket:message="value:gb.cancel" wicket:id="cancel" /></div>\r
+       </div>\r
+       </div>\r
+       </form>\r
+</div>\r
+</body>\r
+\r
+<wicket:fragment wicket:id="responsibleFragment">\r
+       <th><wicket:message key="gb.responsible"></wicket:message></th><td class="edit"><select class="input-large" wicket:id="responsible"></select></td>\r
+</wicket:fragment>\r
+\r
+<wicket:fragment wicket:id="milestoneFragment">\r
+       <th><wicket:message key="gb.milestone"></wicket:message></th><td class="edit"><select class="input-large" wicket:id="milestone"></select></td>\r
+</wicket:fragment>\r
+\r
+</wicket:extend>\r
+</html>
\ No newline at end of file
diff --git a/src/main/java/com/gitblit/wicket/pages/NewTicketPage.java b/src/main/java/com/gitblit/wicket/pages/NewTicketPage.java
new file mode 100644 (file)
index 0000000..17ad1d1
--- /dev/null
@@ -0,0 +1,202 @@
+/*\r
+ * Copyright 2014 gitblit.com.\r
+ *\r
+ * Licensed under the Apache License, Version 2.0 (the "License");\r
+ * you may not use this file except in compliance with the License.\r
+ * You may obtain a copy of the License at\r
+ *\r
+ *     http://www.apache.org/licenses/LICENSE-2.0\r
+ *\r
+ * Unless required by applicable law or agreed to in writing, software\r
+ * distributed under the License is distributed on an "AS IS" BASIS,\r
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r
+ * See the License for the specific language governing permissions and\r
+ * limitations under the License.\r
+ */\r
+package com.gitblit.wicket.pages;\r
+\r
+import java.util.ArrayList;\r
+import java.util.Arrays;\r
+import java.util.Collections;\r
+import java.util.List;\r
+\r
+import org.apache.wicket.PageParameters;\r
+import org.apache.wicket.markup.html.basic.Label;\r
+import org.apache.wicket.markup.html.form.Button;\r
+import org.apache.wicket.markup.html.form.DropDownChoice;\r
+import org.apache.wicket.markup.html.form.Form;\r
+import org.apache.wicket.markup.html.form.TextField;\r
+import org.apache.wicket.markup.html.panel.Fragment;\r
+import org.apache.wicket.model.IModel;\r
+import org.apache.wicket.model.Model;\r
+\r
+import com.gitblit.Constants.AccessPermission;\r
+import com.gitblit.models.RegistrantAccessPermission;\r
+import com.gitblit.models.TicketModel;\r
+import com.gitblit.models.TicketModel.Change;\r
+import com.gitblit.models.TicketModel.Field;\r
+import com.gitblit.models.TicketModel.Status;\r
+import com.gitblit.models.UserModel;\r
+import com.gitblit.tickets.TicketMilestone;\r
+import com.gitblit.tickets.TicketNotifier;\r
+import com.gitblit.tickets.TicketResponsible;\r
+import com.gitblit.utils.StringUtils;\r
+import com.gitblit.wicket.GitBlitWebSession;\r
+import com.gitblit.wicket.WicketUtils;\r
+import com.gitblit.wicket.panels.MarkdownTextArea;\r
+\r
+/**\r
+ * Page for creating a new ticket.\r
+ *\r
+ * @author James Moger\r
+ *\r
+ */\r
+public class NewTicketPage extends RepositoryPage {\r
+\r
+       private IModel<TicketModel.Type> typeModel;\r
+\r
+       private IModel<String> titleModel;\r
+\r
+       private MarkdownTextArea descriptionEditor;\r
+\r
+       private IModel<String> topicModel;\r
+\r
+       private IModel<TicketResponsible> responsibleModel;\r
+\r
+       private IModel<TicketMilestone> milestoneModel;\r
+\r
+       private Label descriptionPreview;\r
+\r
+       public NewTicketPage(PageParameters params) {\r
+               super(params);\r
+\r
+               UserModel currentUser = GitBlitWebSession.get().getUser();\r
+               if (currentUser == null) {\r
+                       currentUser = UserModel.ANONYMOUS;\r
+               }\r
+\r
+               if (!currentUser.isAuthenticated || !app().tickets().isAcceptingNewTickets(getRepositoryModel())) {\r
+                       // tickets prohibited\r
+                       setResponsePage(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));\r
+               }\r
+\r
+               typeModel = Model.of(TicketModel.Type.defaultType);\r
+               titleModel = Model.of();\r
+               topicModel = Model.of();\r
+               responsibleModel = Model.of();\r
+               milestoneModel = Model.of();\r
+\r
+               setStatelessHint(false);\r
+               setOutputMarkupId(true);\r
+\r
+               Form<Void> form = new Form<Void>("editForm") {\r
+\r
+                       private static final long serialVersionUID = 1L;\r
+\r
+                       @Override\r
+                       protected void onSubmit() {\r
+                               String createdBy = GitBlitWebSession.get().getUsername();\r
+                               Change change = new Change(createdBy);\r
+                               change.setField(Field.title, titleModel.getObject());\r
+                               change.setField(Field.body, descriptionEditor.getText());\r
+                               String topic = topicModel.getObject();\r
+                               if (!StringUtils.isEmpty(topic)) {\r
+                                       change.setField(Field.topic, topic);\r
+                               }\r
+\r
+                               // type\r
+                               TicketModel.Type type = TicketModel.Type.defaultType;\r
+                               if (typeModel.getObject() != null) {\r
+                                       type = typeModel.getObject();\r
+                               }\r
+                               change.setField(Field.type, type);\r
+\r
+                               // responsible\r
+                               TicketResponsible responsible = responsibleModel == null ? null : responsibleModel.getObject();\r
+                               if (responsible != null) {\r
+                                       change.setField(Field.responsible, responsible.username);\r
+                               }\r
+\r
+                               // milestone\r
+                               TicketMilestone milestone = milestoneModel == null ? null : milestoneModel.getObject();\r
+                               if (milestone != null) {\r
+                                       change.setField(Field.milestone, milestone.name);\r
+                               }\r
+\r
+                               TicketModel ticket = app().tickets().createTicket(getRepositoryModel(), 0L, change);\r
+                               if (ticket != null) {\r
+                                       TicketNotifier notifier = app().tickets().createNotifier();\r
+                                       notifier.sendMailing(ticket);\r
+                                       setResponsePage(TicketsPage.class, WicketUtils.newObjectParameter(getRepositoryModel().name, "" + ticket.number));\r
+                               } else {\r
+                                       // TODO error\r
+                               }\r
+                       }\r
+               };\r
+               add(form);\r
+\r
+               form.add(new DropDownChoice<TicketModel.Type>("type", typeModel, Arrays.asList(TicketModel.Type.choices())));\r
+               form.add(new TextField<String>("title", titleModel));\r
+               form.add(new TextField<String>("topic", topicModel));\r
+\r
+               final IModel<String> markdownPreviewModel = new Model<String>();\r
+               descriptionPreview = new Label("descriptionPreview", markdownPreviewModel);\r
+               descriptionPreview.setEscapeModelStrings(false);\r
+               descriptionPreview.setOutputMarkupId(true);\r
+               form.add(descriptionPreview);\r
+\r
+               descriptionEditor = new MarkdownTextArea("description", markdownPreviewModel, descriptionPreview);\r
+               descriptionEditor.setRepository(repositoryName);\r
+               form.add(descriptionEditor);\r
+\r
+               if (currentUser != null && currentUser.isAuthenticated && currentUser.canPush(getRepositoryModel())) {\r
+                       // responsible\r
+                       List<TicketResponsible> responsibles = new ArrayList<TicketResponsible>();\r
+                       for (RegistrantAccessPermission rp : app().repositories().getUserAccessPermissions(getRepositoryModel())) {\r
+                               if (rp.permission.atLeast(AccessPermission.PUSH) && !rp.isTeam()) {\r
+                                       UserModel user = app().users().getUserModel(rp.registrant);\r
+                                       if (user != null) {\r
+                                               responsibles.add(new TicketResponsible(user));\r
+                                       }\r
+                               }\r
+                       }\r
+                       Collections.sort(responsibles);\r
+                       Fragment responsible = new Fragment("responsible", "responsibleFragment", this);\r
+                       responsible.add(new DropDownChoice<TicketResponsible>("responsible", responsibleModel, responsibles));\r
+                       form.add(responsible.setVisible(!responsibles.isEmpty()));\r
+\r
+                       // milestone\r
+                       List<TicketMilestone> milestones = app().tickets().getMilestones(getRepositoryModel(), Status.Open);\r
+                       Fragment milestone = new Fragment("milestone", "milestoneFragment", this);\r
+                       milestone.add(new DropDownChoice<TicketMilestone>("milestone", milestoneModel, milestones));\r
+                       form.add(milestone.setVisible(!milestones.isEmpty()));\r
+               } else {\r
+                       // user does not have permission to assign milestone or responsible\r
+                       form.add(new Label("responsible").setVisible(false));\r
+                       form.add(new Label("milestone").setVisible(false));\r
+               }\r
+\r
+               form.add(new Button("create"));\r
+               Button cancel = new Button("cancel") {\r
+                       private static final long serialVersionUID = 1L;\r
+\r
+                       @Override\r
+                       public void onSubmit() {\r
+                               setResponsePage(TicketsPage.class, getPageParameters());\r
+                       }\r
+               };\r
+               cancel.setDefaultFormProcessing(false);\r
+               form.add(cancel);\r
+\r
+       }\r
+\r
+       @Override\r
+       protected String getPageName() {\r
+               return getString("gb.newTicket");\r
+       }\r
+\r
+       @Override\r
+       protected Class<? extends BasePage> getRepoNavPageClass() {\r
+               return TicketsPage.class;\r
+       }\r
+}\r
diff --git a/src/main/java/com/gitblit/wicket/pages/NoTicketsPage.html b/src/main/java/com/gitblit/wicket/pages/NoTicketsPage.html
new file mode 100644 (file)
index 0000000..3eb5635
--- /dev/null
@@ -0,0 +1,21 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">\r
+<html xmlns="http://www.w3.org/1999/xhtml"  \r
+      xmlns:wicket="http://wicket.apache.org/dtds.data/wicket-xhtml1.3-strict.dtd"  \r
+      xml:lang="en"  \r
+      lang="en"> \r
+\r
+<wicket:extend>\r
+       <!-- No tickets -->\r
+       <div class="featureWelcome">\r
+               <div class="row">\r
+                       <div class="icon span2"><i class="fa fa-ticket"></i></div>\r
+                       <div class="span9">             \r
+                               <h1><wicket:message key="gb.tickets"></wicket:message></h1>\r
+                               <wicket:message key="gb.ticketsWelcome"></wicket:message>\r
+                               <p></p>\r
+                               <a wicket:id="newticket" class="btn btn-appmenu"><wicket:message key="gb.createFirstTicket"></wicket:message></a>\r
+                       </div>\r
+               </div>\r
+       </div>\r
+</wicket:extend>\r
+</html>
\ No newline at end of file
diff --git a/src/main/java/com/gitblit/wicket/pages/NoTicketsPage.java b/src/main/java/com/gitblit/wicket/pages/NoTicketsPage.java
new file mode 100644 (file)
index 0000000..8e98a00
--- /dev/null
@@ -0,0 +1,44 @@
+/*\r
+ * Copyright 2013 gitblit.com.\r
+ *\r
+ * Licensed under the Apache License, Version 2.0 (the "License");\r
+ * you may not use this file except in compliance with the License.\r
+ * You may obtain a copy of the License at\r
+ *\r
+ *     http://www.apache.org/licenses/LICENSE-2.0\r
+ *\r
+ * Unless required by applicable law or agreed to in writing, software\r
+ * distributed under the License is distributed on an "AS IS" BASIS,\r
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r
+ * See the License for the specific language governing permissions and\r
+ * limitations under the License.\r
+ */\r
+package com.gitblit.wicket.pages;\r
+\r
+import org.apache.wicket.PageParameters;\r
+import org.apache.wicket.markup.html.link.BookmarkablePageLink;\r
+\r
+import com.gitblit.models.UserModel;\r
+import com.gitblit.wicket.GitBlitWebSession;\r
+import com.gitblit.wicket.WicketUtils;\r
+\r
+public class NoTicketsPage extends RepositoryPage {\r
+\r
+       public NoTicketsPage(PageParameters params) {\r
+               super(params);\r
+\r
+               UserModel user = GitBlitWebSession.get().getUser();\r
+               boolean isAuthenticated = user != null && user.isAuthenticated;\r
+               add(new BookmarkablePageLink<Void>("newticket", NewTicketPage.class, WicketUtils.newRepositoryParameter(repositoryName)).setVisible(isAuthenticated));\r
+       }\r
+\r
+       @Override\r
+       protected String getPageName() {\r
+               return getString("gb.tickets");\r
+       }\r
+\r
+       @Override\r
+       protected Class<? extends BasePage> getRepoNavPageClass() {\r
+               return TicketsPage.class;\r
+       }\r
+}\r
index 0acc6dbc1b0b3dd1b22c0affbe2736ee903700ff..cb4f1b6738ef0033c2692e052e85fa55d137cc9c 100644 (file)
@@ -38,6 +38,7 @@
                                        <div>\r
                                                <div class="hidden-phone btn-group pull-right" style="margin-top:5px;">\r
                                                        <!-- future spot for other repo buttons -->\r
+                                                       <a class="btn" wicket:id="newTicketLink"></a>\r
                                                        <a class="btn" wicket:id="starLink"></a>\r
                                                        <a class="btn" wicket:id="unstarLink"></a>\r
                                                        <a class="btn" wicket:id="myForkLink"><img style="border:0px;vertical-align:middle;" src="fork-black_16x16.png"></img> <wicket:message key="gb.myFork"></wicket:message></a>\r
index 079cb2e9bd11406b4088f9f3163b54333067cb17..86df4565220937184af0e5ed9e8de9ac10099558 100644 (file)
@@ -56,6 +56,7 @@ import com.gitblit.models.UserModel;
 import com.gitblit.models.UserRepositoryPreferences;\r
 import com.gitblit.servlet.PagesServlet;\r
 import com.gitblit.servlet.SyndicationServlet;\r
+import com.gitblit.tickets.TicketIndexer.Lucene;\r
 import com.gitblit.utils.ArrayUtils;\r
 import com.gitblit.utils.DeepCopier;\r
 import com.gitblit.utils.JGitUtils;\r
@@ -95,7 +96,7 @@ public abstract class RepositoryPage extends RootPage {
        public RepositoryPage(PageParameters params) {\r
                super(params);\r
                repositoryName = WicketUtils.getRepositoryName(params);\r
-               String root =StringUtils.getFirstPathElement(repositoryName);\r
+               String root = StringUtils.getFirstPathElement(repositoryName);\r
                if (StringUtils.isEmpty(root)) {\r
                        projectName = app().settings().getString(Keys.web.repositoryRootGroupName, "main");\r
                } else {\r
@@ -200,11 +201,18 @@ public abstract class RepositoryPage extends RootPage {
                }\r
                pages.put("commits", new PageRegistration("gb.commits", LogPage.class, params));\r
                pages.put("tree", new PageRegistration("gb.tree", TreePage.class, params));\r
+               if (app().tickets().isReady() && (app().tickets().isAcceptingNewTickets(getRepositoryModel()) || app().tickets().hasTickets(getRepositoryModel()))) {\r
+                       PageParameters tParams = new PageParameters(params);\r
+                       for (String state : TicketsPage.openStatii) {\r
+                               tParams.add(Lucene.status.name(), state);\r
+                       }\r
+                       pages.put("tickets", new PageRegistration("gb.tickets", TicketsPage.class, tParams));
+               }
                pages.put("docs", new PageRegistration("gb.docs", DocsPage.class, params, true));\r
-               pages.put("compare", new PageRegistration("gb.compare", ComparePage.class, params, true));\r
                if (app().settings().getBoolean(Keys.web.allowForking, true)) {\r
                        pages.put("forks", new PageRegistration("gb.forks", ForksPage.class, params, true));\r
                }\r
+               pages.put("compare", new PageRegistration("gb.compare", ComparePage.class, params, true));
 \r
                // conditional links\r
                // per-repository extra page links\r
@@ -288,6 +296,14 @@ public abstract class RepositoryPage extends RootPage {
                        }\r
                }\r
 \r
+               // new ticket button\r
+               if (user.isAuthenticated && app().tickets().isAcceptingNewTickets(getRepositoryModel())) {\r
+                       String newTicketUrl = getRequestCycle().urlFor(NewTicketPage.class, WicketUtils.newRepositoryParameter(repositoryName)).toString();\r
+                       addToolbarButton("newTicketLink", "fa fa-ticket", getString("gb.new"), newTicketUrl);\r
+               } else {\r
+                       add(new Label("newTicketLink").setVisible(false));\r
+               }\r
+\r
                // (un)star link allows a user to star a repository\r
                if (user.isAuthenticated) {\r
                        PageParameters starParams = DeepCopier.copy(getPageParameters());\r
diff --git a/src/main/java/com/gitblit/wicket/pages/TicketBasePage.java b/src/main/java/com/gitblit/wicket/pages/TicketBasePage.java
new file mode 100644 (file)
index 0000000..3736cdd
--- /dev/null
@@ -0,0 +1,124 @@
+/*\r
+ * Copyright 2013 gitblit.com.\r
+ *\r
+ * Licensed under the Apache License, Version 2.0 (the "License");\r
+ * you may not use this file except in compliance with the License.\r
+ * You may obtain a copy of the License at\r
+ *\r
+ *     http://www.apache.org/licenses/LICENSE-2.0\r
+ *\r
+ * Unless required by applicable law or agreed to in writing, software\r
+ * distributed under the License is distributed on an "AS IS" BASIS,\r
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r
+ * See the License for the specific language governing permissions and\r
+ * limitations under the License.\r
+ */\r
+package com.gitblit.wicket.pages;\r
+\r
+import org.apache.wicket.PageParameters;\r
+import org.apache.wicket.markup.html.basic.Label;\r
+\r
+import com.gitblit.models.TicketModel;\r
+import com.gitblit.models.TicketModel.Status;\r
+import com.gitblit.models.TicketModel.Type;\r
+import com.gitblit.wicket.WicketUtils;\r
+\r
+public abstract class TicketBasePage extends RepositoryPage {\r
+\r
+       public TicketBasePage(PageParameters params) {\r
+               super(params);\r
+       }\r
+\r
+       protected Label getStateIcon(String wicketId, TicketModel ticket) {\r
+               return getStateIcon(wicketId, ticket.type, ticket.status);\r
+       }\r
+\r
+       protected Label getStateIcon(String wicketId, Type type, Status state) {\r
+               Label label = new Label(wicketId);\r
+               if (type == null) {\r
+                       type = Type.defaultType;\r
+               }\r
+               switch (type) {\r
+               case Proposal:\r
+                       WicketUtils.setCssClass(label, "fa fa-code-fork");\r
+                       break;\r
+               case Bug:\r
+                       WicketUtils.setCssClass(label, "fa fa-bug");\r
+                       break;\r
+               case Enhancement:\r
+                       WicketUtils.setCssClass(label, "fa fa-magic");\r
+                       break;\r
+               case Question:\r
+                       WicketUtils.setCssClass(label, "fa fa-question");\r
+                       break;\r
+               default:\r
+                       // standard ticket\r
+                       WicketUtils.setCssClass(label, "fa fa-ticket");\r
+               }\r
+               WicketUtils.setHtmlTooltip(label, getTypeState(type, state));\r
+               return label;\r
+       }\r
+\r
+       protected String getTypeState(Type type, Status state) {\r
+               return state.toString() + " " + type.toString();\r
+       }\r
+\r
+       protected String getLozengeClass(Status status, boolean subtle) {\r
+               if (status == null) {\r
+                       status = Status.New;\r
+               }\r
+               String css = "";\r
+               switch (status) {\r
+               case Declined:\r
+               case Duplicate:\r
+               case Invalid:\r
+               case Wontfix:\r
+                       css = "aui-lozenge-error";\r
+                       break;\r
+               case Fixed:\r
+               case Merged:\r
+               case Resolved:\r
+                       css = "aui-lozenge-success";\r
+                       break;\r
+               case New:\r
+                       css = "aui-lozenge-complete";\r
+                       break;\r
+               case On_Hold:\r
+                       css = "aui-lozenge-current";\r
+                       break;\r
+               default:\r
+                       css = "";\r
+                       break;\r
+               }\r
+\r
+               return "aui-lozenge" + (subtle ? " aui-lozenge-subtle": "") + (css.isEmpty() ? "" : " ") + css;\r
+       }\r
+\r
+       protected String getStatusClass(Status status) {\r
+               String css = "";\r
+               switch (status) {\r
+               case Declined:\r
+               case Duplicate:\r
+               case Invalid:\r
+               case Wontfix:\r
+                       css = "resolution-error";\r
+                       break;\r
+               case Fixed:\r
+               case Merged:\r
+               case Resolved:\r
+                       css = "resolution-success";\r
+                       break;\r
+               case New:\r
+                       css = "resolution-complete";\r
+                       break;\r
+               case On_Hold:\r
+                       css = "resolution-current";\r
+                       break;\r
+               default:\r
+                       css = "";\r
+                       break;\r
+               }\r
+\r
+               return "resolution" + (css.isEmpty() ? "" : " ") + css;\r
+       }\r
+}\r
diff --git a/src/main/java/com/gitblit/wicket/pages/TicketPage.html b/src/main/java/com/gitblit/wicket/pages/TicketPage.html
new file mode 100644 (file)
index 0000000..2e0288a
--- /dev/null
@@ -0,0 +1,577 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">\r
+<html xmlns="http://www.w3.org/1999/xhtml"  \r
+      xmlns:wicket="http://wicket.apache.org/dtds.data/wicket-xhtml1.3-strict.dtd"  \r
+      xml:lang="en"  \r
+      lang="en"> \r
+\r
+<body>\r
+<wicket:extend>\r
+\r
+<!-- HEADER -->\r
+<div style="padding: 10px 0px 15px;">\r
+       <div style="display:inline-block;" class="ticket-title"><span wicket:id="ticketTitle">[ticket title]</span></div>\r
+       <a style="padding-left:10px;" class="ticket-number" wicket:id="ticketNumber"></a>\r
+       <div style="display:inline-block;padding: 0px 10px;vertical-align:top;"><span wicket:id="headerStatus"></span></div>    \r
+       <div class="hidden-phone hidden-tablet pull-right"><div wicket:id="diffstat"></div></div>\r
+</div>\r
+\r
+<!-- TAB NAMES -->\r
+<ul class="nav nav-tabs">\r
+       <li class="active"><a data-toggle="tab" href="#discussion">\r
+               <i style="color:#888;"class="fa fa-comments"></i> <span class="hidden-phone"><wicket:message key="gb.discussion"></wicket:message></span> <span class="lwbadge" wicket:id="commentCount"></span></a>\r
+       </li>\r
+       <li><a data-toggle="tab" href="#commits">\r
+               <i style="color:#888;"class="fa fa-code"></i> <span class="hidden-phone"><wicket:message key="gb.commits"></wicket:message></span> <span class="lwbadge" wicket:id="commitCount"></span></a>\r
+       </li>\r
+       <li><a data-toggle="tab" href="#activity">\r
+               <i style="color:#888;"class="fa fa-clock-o"></i> <span class="hidden-phone"><wicket:message key="gb.activity"></wicket:message></span></a>\r
+       </li>\r
+</ul>\r
+       \r
+<!-- TABS -->\r
+<div class="tab-content">\r
+               \r
+       <!-- DISCUSSION TAB -->\r
+       <div class="tab-pane active" id="discussion">\r
+               <div class="row">\r
+                               \r
+                       <!-- LEFT SIDE -->\r
+                       <div class="span8">             \r
+                               <div class="ticket-meta-middle">\r
+                                       <!-- creator -->\r
+                                       <span class="attribution-emphasize" wicket:id="whoCreated">[someone]</span><span wicket:id="creationMessage" class="attribution-text" style="padding: 0px 3px;">[created this ticket]</span> <span class="attribution-emphasize" wicket:id="whenCreated">[when created]</span>\r
+                               </div>\r
+                               <div class="ticket-meta-bottom"">\r
+                                       <div class="ticket-text markdown" wicket:id="ticketDescription">[description]</div>\r
+                               </div>\r
+                               \r
+                               <!-- COMMENTS and STATUS CHANGES (DISCUSSIONS TAB) -->\r
+                               <div wicket:id="discussion"></div>\r
+               \r
+               \r
+                               <!-- ADD COMMENT (DISCUSSIONS TAB) -->\r
+                               <div id="addcomment" wicket:id="newComment"></div>\r
+                       </div>\r
+\r
+                       <!-- RIGHT SIDE -->     \r
+                       <div class="span4 hidden-phone">\r
+                               <div class="status-display" style="padding-bottom: 5px;">\r
+                                       <div wicket:id="ticketStatus" style="display:block;padding: 5px 10px 10px;">[ticket status]</div>\r
+                               </div>          \r
+                               <div wicket:id="labels" style="border-top: 1px solid #ccc;padding: 5px 0px;">\r
+                                       <span class="label ticketLabel" wicket:id="label">[label]</span>\r
+                               </div>\r
+                               \r
+                               <div wicket:id="controls"></div>\r
+                               \r
+                               <div style="border: 1px solid #ccc;padding: 10px;margin: 5px 0px;">\r
+                                       <table class="summary" style="width: 100%">\r
+                                               <tr><th><wicket:message key="gb.type"></wicket:message></th><td><span wicket:id="ticketType">[type]</span></td></tr>\r
+                                               <tr><th><wicket:message key="gb.topic"></wicket:message></th><td><span wicket:id="ticketTopic">[topic]</span></td></tr>\r
+                                               <tr><th><wicket:message key="gb.responsible"></wicket:message></th><td><span wicket:id="responsible">[responsible]</span></td></tr>\r
+                                               <tr><th><wicket:message key="gb.milestone"></wicket:message></th><td><span wicket:id="milestone">[milestone]</span></td></tr>\r
+                                               <tr><th><wicket:message key="gb.votes"></wicket:message></th><td><span wicket:id="votes" class="badge">1</span> <a style="padding-left:5px" wicket:id="voteLink" href="#">vote</a></td></tr>\r
+                                               <tr><th><wicket:message key="gb.watchers"></wicket:message></th><td><span wicket:id="watchers" class="badge">1</span> <a style="padding-left:5px" wicket:id="watchLink" href="#">watch</a></td></tr>\r
+                                               <tr><th><wicket:message key="gb.export"></wicket:message></th><td><a rel="nofollow" target="_blank" wicket:id="exportJson"></a></td></tr>\r
+                                               \r
+                                       </table>\r
+                               </div>\r
+                               \r
+                               <div>\r
+                                       <span class="attribution-text" wicket:id="participantsLabel"></span>\r
+                                       <span wicket:id="participants"><span style="padding: 0px 2px;" wicket:id="participant"></span></span>\r
+                               </div>\r
+                       </div>\r
+               </div>          \r
+               \r
+       </div>\r
+       \r
+       \r
+       <!-- COMMITS TAB -->\r
+       <div class="tab-pane" id="commits">\r
+               <div wicket:id="patchset"></div>\r
+       </div>\r
+       \r
+       \r
+       <!-- ACTIVITY TAB -->\r
+       <div class="tab-pane" id="activity">\r
+               <div wicket:id="activity"></div>        \r
+       </div>\r
+       \r
+</div> <!-- END TABS -->\r
+\r
+\r
+<!-- BARNUM DOWNLOAD MODAL -->\r
+<div id="ptModal" class="modal hide fade" tabindex="-1" role="dialog" aria-labelledby="ptModalLabel" aria-hidden="true">\r
+  <div class="modal-header">\r
+    <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>\r
+    <h3 id="ptModalLabel"><img src="barnum_32x32.png"></img> Barnum <small><wicket:message key="gb.ptDescription"></wicket:message></small></h3>\r
+  </div>\r
+  <div class="modal-body">\r
+    <p><wicket:message key="gb.ptDescription1"></wicket:message></p>\r
+    \r
+    <h4><wicket:message key="gb.ptSimplifiedCollaboration"></wicket:message></h4>\r
+    <pre class="gitcommand">\r
+pt checkout 123\r
+...\r
+git commit\r
+pt push</pre>\r
+    \r
+    <h4><wicket:message key="gb.ptSimplifiedMerge"></wicket:message></h4>\r
+    <pre class="gitcommand">pt pull 123</pre>    \r
+    <p><wicket:message key="gb.ptDescription2"></wicket:message></p>\r
+  </div>\r
+  <div class="modal-footer">\r
+    <a class="btn btn-appmenu" href="/pt" ><wicket:message key="gb.download"></wicket:message></a>\r
+  </div>\r
+</div>\r
+\r
+\r
+<!-- MILESTONE PROGRESS FRAGMENT -->\r
+<wicket:fragment wicket:id="milestoneProgressFragment">\r
+       <div style="display:inline-block;padding-right: 10px" wicket:id="link"></div>\r
+       <div style="display:inline-block;margin-bottom: 0px;width: 100px;height:10px;" class="progress progress-success">\r
+               <div class="bar" wicket:id="progress"></div>\r
+       </div>  \r
+</wicket:fragment>\r
+\r
+\r
+<!-- TICKET CONTROLS FRAGMENT -->\r
+<wicket:fragment wicket:id="controlsFragment">\r
+       <div class="hidden-phone hidden-tablet">\r
+               <div class="btn-group" style="display:inline-block;">\r
+                       <a class="btn btn-small dropdown-toggle" data-toggle="dropdown" href="#"><wicket:message key="gb.status"></wicket:message> <span class="caret"></span></a>\r
+                       <ul class="dropdown-menu">\r
+                               <li wicket:id="newStatus"><a wicket:id="link">[status]</a></li>                                                 \r
+                       </ul>\r
+               </div>\r
+               \r
+               <div class="btn-group" style="display:inline-block;">   \r
+                       <a class="btn btn-small dropdown-toggle" data-toggle="dropdown" href="#"><wicket:message key="gb.responsible"></wicket:message> <span class="caret"></span></a>\r
+                       <ul class="dropdown-menu">\r
+                               <li wicket:id="newResponsible"><a wicket:id="link">[responsible]</a></li>                                               \r
+                       </ul>\r
+               </div>\r
+               \r
+               <div class="btn-group" style="display:inline-block;">\r
+                       <a class="btn btn-small dropdown-toggle" data-toggle="dropdown" href="#"><wicket:message key="gb.milestone"></wicket:message> <span class="caret"></span></a>\r
+                       <ul class="dropdown-menu">\r
+                               <li wicket:id="newMilestone"><a wicket:id="link">[milestone]</a></li>                                                   \r
+                       </ul>\r
+               </div>\r
+               \r
+               <div class="btn-group" style="display:inline-block;">\r
+                       <a class="btn btn-small" wicket:id="editLink"></a>\r
+               </div>\r
+       </div>\r
+</wicket:fragment>\r
+\r
+\r
+<!-- STATUS INDICATOR FRAGMENT -->\r
+<wicket:fragment wicket:id="ticketStatusFragment">\r
+       <div style="font-size:2.5em;padding-bottom: 5px;">\r
+               <i wicket:id="ticketIcon">[ticket type]</i>\r
+       </div>\r
+       <div style="font-size:1.5em;" wicket:id="ticketStatus">[ticket status]</div>    \r
+</wicket:fragment>\r
+\r
+\r
+<!-- DISCUSSION FRAGMENT -->\r
+<wicket:fragment wicket:id="discussionFragment">\r
+       <h3 style="padding-top:10px;"><wicket:message key="gb.comments"></wicket:message></h3>\r
+       <div wicket:id="discussion">\r
+               <div style="padding: 10px 0px;" wicket:id="entry"></div>\r
+       </div>\r
+</wicket:fragment>\r
+\r
+<!-- NEW COMMENT FRAGMENT -->\r
+<wicket:fragment wicket:id="newCommentFragment">\r
+       <div class="row">\r
+               <div class="span8">\r
+                       <hr/>\r
+               </div>\r
+       </div>\r
+       \r
+       <h3 style="padding:0px 0px 10px;"><wicket:message key="gb.addComment"></wicket:message></h3>\r
+       \r
+       <div class="row">\r
+               <div class="span1 hidden-phone" style="text-align:right;">\r
+                       <span wicket:id="newCommentAvatar">[avatar]</span>\r
+               </div>\r
+               <div class="span7 attribution-border" style="background-color:#fbfbfb;">\r
+                       <div class="hidden-phone attribution-triangle"></div>\r
+                       <div wicket:id="commentPanel"></div>\r
+               </div>  \r
+       </div>\r
+</wicket:fragment>\r
+\r
+\r
+<!-- COMMENT FRAGMENT -->\r
+<wicket:fragment wicket:id="commentFragment">\r
+<div class="row">\r
+       <div class="span1 hidden-phone" style="text-align:right;">\r
+               <span wicket:id="changeAvatar">[avatar]</span>\r
+       </div>\r
+       <div class="span7 attribution-border">\r
+               <!-- <div class="hidden-phone attribution-triangle"></div> -->\r
+               <div class="attribution-header" style="border-radius:20px;">\r
+                        <span class="indicator-large-dark"><i wicket:id="commentIcon"></i></span><span class="attribution-emphasize" wicket:id="changeAuthor">[author]</span> <span class="attribution-text"><span class="hidden-phone"><wicket:message key="gb.commented">[commented]</wicket:message></span></span><p class="attribution-header-pullright" ><span class="attribution-date" wicket:id="changeDate">[comment date]</span><a class="attribution-link" wicket:id="changeLink"><i class="iconic-link"></i></a></p>\r
+               </div>\r
+               <div class="markdown attribution-comment">\r
+                       <div class="ticket-text" wicket:id="comment">[comment text]</div>\r
+               </div>\r
+       </div>\r
+</div>\r
+</wicket:fragment>\r
+\r
+\r
+<!-- STATUS CHANGE FRAGMENT -->\r
+<wicket:fragment wicket:id="statusFragment">\r
+<div class="row" style="opacity: 0.5;filter: alpha(opacity=50);">\r
+       <div class="span7 offset1">             \r
+               <div style="padding: 8px;border: 1px solid translucent;">\r
+                        <span class="indicator-large-dark"><i></i></span><span class="attribution-emphasize" wicket:id="changeAuthor">[author]</span> <span class="attribution-text"><span class="hidden-phone"><wicket:message key="gb.changedStatus">[changed status]</wicket:message></span></span> <span style="padding-left:10px;"><span wicket:id="statusChange"></span></span><p class="attribution-header-pullright" ><span class="attribution-date" wicket:id="changeDate">[comment date]</span><a class="attribution-link" wicket:id="changeLink"><i class="iconic-link"></i></a></p>\r
+               </div>\r
+       </div>          \r
+</div>\r
+</wicket:fragment>\r
+\r
+\r
+<!-- BOUNDARY FRAGMENT -->\r
+<wicket:fragment wicket:id="boundaryFragment">\r
+<div class="row" style="padding: 15px 0px 10px 0px;">\r
+       <div class="span7 offset1" style="border-top: 2px dotted #999;" />\r
+</div>\r
+</wicket:fragment>\r
+\r
+\r
+<!-- MERGE/CLOSE FRAGMENT -->\r
+<wicket:fragment wicket:id="mergeCloseFragment">\r
+<div wicket:id="merge" style="padding-top: 10px;"></div>       \r
+<div wicket:id="close"></div>\r
+<div wicket:id="boundary"></div>\r
+</wicket:fragment>\r
+\r
+\r
+<!-- MERGE FRAGMENT -->\r
+<wicket:fragment wicket:id="mergeFragment">\r
+<div class="row">\r
+       <div class="span7 offset1">\r
+               <span class="status-change aui-lozenge aui-lozenge-success"><wicket:message key="gb.merged"></wicket:message></span>\r
+               <span class="attribution-emphasize" wicket:id="changeAuthor">[author]</span> <span class="attribution-text"><wicket:message key="gb.mergedPatchset">[merged patchset]</wicket:message></span>\r
+               <span class="attribution-emphasize" wicket:id="commitLink">[commit]</span> <span style="padding-left:2px;" wicket:id="toBranch"></span>\r
+               <p class="attribution-pullright"><span class="attribution-date" wicket:id="changeDate">[change date]</span></p>\r
+       </div>\r
+</div>\r
+</wicket:fragment>\r
+\r
+\r
+<!-- PROPOSE A PATCHSET FRAGMENT -->\r
+<wicket:fragment wicket:id="proposeFragment">\r
+       <div class="featureWelcome">\r
+               <div class="row">\r
+                       <div class="icon span2 hidden-phone"><i class="fa fa-code"></i></div>\r
+                       <div class="span9">             \r
+                               <h1><wicket:message key="gb.proposePatchset"></wicket:message></h1>\r
+                               <div class="markdown">\r
+                                       <p><wicket:message key="gb.proposePatchsetNote"></wicket:message></p>\r
+                                       <p><span wicket:id="proposeInstructions"></span></p>\r
+                                       <h4><span wicket:id="gitWorkflow"></span></h4>\r
+                                       <div wicket:id="gitWorkflowSteps"></div>\r
+                                       <h4><span wicket:id="ptWorkflow"></span> <small><wicket:message key="gb.ptDescription"></wicket:message> (<a href="#ptModal" role="button" data-toggle="modal"><wicket:message key="gb.about"></wicket:message></a>)</small></h4>\r
+                                       <div wicket:id="ptWorkflowSteps"></div>\r
+                               </div>\r
+                       </div>\r
+               </div>\r
+       </div>\r
+</wicket:fragment>\r
+\r
+\r
+<!-- PATCHSET FRAGMENT -->\r
+<wicket:fragment wicket:id="patchsetFragment">\r
+       <div class="row" style="padding: 0px 0px 20px;">\r
+               <div class="span12 attribution-border">\r
+                       <div wicket:id="panel"></div>           \r
+               </div>\r
+       </div>\r
+       \r
+       <h3><span wicket:id="commitsInPatchset"></span></h3>\r
+       <div class="row">\r
+               <div class="span12">\r
+                       <table class="table tickets">\r
+                               <thead>\r
+                                       <tr>\r
+                                               <th class="hidden-phone"><wicket:message key="gb.author"></wicket:message></th>\r
+                                               <th ><wicket:message key="gb.commit"></wicket:message></th>                                             \r
+                                               <th colspan="2"><wicket:message key="gb.title"></wicket:message></th>           \r
+                                               <th style="text-align: right;"><wicket:message key="gb.date"></wicket:message></th>\r
+                                       </tr>\r
+                               </thead>\r
+                               <tbody>\r
+                                       <tr wicket:id="commit">\r
+                                               <td class="hidden-phone"><span wicket:id="authorAvatar">[avatar]</span> <span wicket:id="author">[author]</span></td>\r
+                                               <td><span class="shortsha1" wicket:id="commitId">[commit id]</span><span class="hidden-phone" style="padding-left: 20px;" wicket:id="diff">[diff]</span></td>                                           \r
+                                               <td><span class="attribution-text" wicket:id="title">[title]</span></td>\r
+                                               <td style="padding:8px 0px;text-align:right;"><span style="padding-right:40px;"><span wicket:id="commitDiffStat"></span></span></td>                    \r
+                                               <td style="text-align:right;"><span class="attribution-date" wicket:id="commitDate">[commit date]</span></td>   \r
+                                       </tr>\r
+                               </tbody>\r
+                       </table>\r
+               </div>  \r
+       </div>\r
+</wicket:fragment>\r
+\r
+\r
+<!-- COLLAPSIBLE PATCHSET (temp) -->\r
+<wicket:fragment wicket:id="collapsiblePatchsetFragment">\r
+<div wicket:id="mergePanel" style="margin-bottom: 10px;"></div>\r
+<div class="accordion" id="accordionPatchset" style="clear:both;margin: 0px;">\r
+<div class="patch-group">\r
+       <div class="accordion-heading">\r
+               <div class="attribution-patch-pullright">       \r
+                       <div style="padding-bottom: 2px;"> \r
+                               <span class="attribution-date" wicket:id="changeDate">[patch date]</span>\r
+                       </div>\r
+\r
+                       <!-- Client commands menu -->\r
+                       <div class="btn-group pull-right hidden-phone hidden-tablet">\r
+                               <a class="btn btn-mini btn-appmenu" data-toggle="collapse" data-parent="#accordionCheckout" href="#bodyCheckout"><wicket:message key="gb.checkout"></wicket:message> <span class="caret"></span></a>\r
+                       </div>\r
+                       \r
+                       <!-- Compare Patchsets menu -->\r
+                       <div class="btn-group pull-right hidden-phone hidden-tablet" style="padding-right: 5px;">\r
+                               <a class="btn btn-mini dropdown-toggle" data-toggle="dropdown" href="#">\r
+                                       <wicket:message key="gb.compare"></wicket:message> <span class="caret"></span>\r
+                               </a>\r
+                               <ul class="dropdown-menu">\r
+                                       <li><span wicket:id="compareMergeBase"></span></li>\r
+                                       <li wicket:id="comparePatch"><span wicket:id="compareLink"></span></li>\r
+                               </ul>\r
+                       </div>\r
+\r
+               </div>\r
+               <div style="padding:8px 10px;">\r
+                       <div>\r
+                       <span class="attribution-emphasize" wicket:id="changeAuthor">[author]</span> <span class="attribution-text"><span wicket:id="uploadedWhat"></span></span>\r
+                       <a wicket:message="title:gb.showHideDetails" data-toggle="collapse" data-parent="#accordionPatchset" href="#bodyPatchset"><i class="fa fa-toggle-down"></i></a>                 \r
+                       </div>\r
+                       <div wicket:id="patchsetStat"></div>\r
+               </div>\r
+       </div>\r
+       \r
+       <div style="padding: 10px;color: #444;background:white;border-top:1px solid #ccc;">\r
+               <div class="pull-right" wicket:id="reviewControls"></div>\r
+               <span style="font-weight:bold;padding-right:10px;"><wicket:message key="gb.reviews"></wicket:message></span> <span wicket:id="reviews" style="padding-right:10px;"><i style="font-size:16px;" wicket:id="score"></i> <span wicket:id="reviewer"></span></span>\r
+       </div>                  \r
+                               \r
+       <div id="bodyPatchset" class="accordion-body collapse" style="clear:both;">\r
+               <div class="accordion-inner">\r
+                       <!-- changed paths -->\r
+                       <table class="pretty" style="border: 0px;">\r
+                               <tr wicket:id="changedPath">\r
+                                       <td class="changeType"><span wicket:id="changeType">[change type]</span></td>\r
+                                       <td class="path"><span wicket:id="pathName">[commit path]</span></td>                   \r
+                                       <td class="hidden-phone rightAlign">                                            \r
+                                               <span class="hidden-tablet" style="padding-right:20px;" wicket:id="diffStat"></span>\r
+                                               <span class="link" style="white-space: nowrap;">\r
+                                                       <a wicket:id="diff"><wicket:message key="gb.diff"></wicket:message></a> | <a wicket:id="view"><wicket:message key="gb.view"></wicket:message></a>\r
+                                               </span>\r
+                                       </td>\r
+                               </tr>\r
+                       </table>\r
+               </div>\r
+       </div>\r
+</div>\r
+<div id="bodyCheckout" class="accordion-body collapse" style="background-color:#fbfbfb;clear:both;">\r
+  <div class="alert submit-info" style="padding:4px;">\r
+    <div class="merge-panel" style="border: 1px solid #F1CB82;">\r
+      <div class="ticket-text">  \r
+        <h4><wicket:message key="gb.checkoutViaCommandLine"></wicket:message></h4>\r
+        <p><wicket:message key="gb.checkoutViaCommandLineNote"></wicket:message></p>\r
+\r
+        <h4>Git</h4>\r
+        <p class="step">\r
+                 <b><span wicket:id="gitStep1"></span>:</b> <wicket:message key="gb.checkoutStep1"></wicket:message> <span wicket:id="gitCopyStep1"></span>\r
+           </p>\r
+           <pre wicket:id="gitPreStep1" class="gitcommand"></pre>\r
+           <p class="step">\r
+                 <b><span wicket:id="gitStep2"></span>:</b> <wicket:message key="gb.checkoutStep2"></wicket:message> <span wicket:id="gitCopyStep2"></span>\r
+           </p>\r
+           <pre wicket:id="gitPreStep2" class="gitcommand"></pre>\r
+           \r
+               <hr/>\r
+        <h4>Barnum <small><wicket:message key="gb.ptDescription"></wicket:message> (<a href="#ptModal" role="button" data-toggle="modal"><wicket:message key="gb.about"></wicket:message></a>)</small> </h4>\r
+        <p class="step">\r
+                 <wicket:message key="gb.ptCheckout"></wicket:message> <span wicket:id="ptCopyStep"></span>\r
+           </p>\r
+           <pre wicket:id="ptPreStep" class="gitcommand"></pre>\r
+     </div>\r
+   </div>\r
+ </div>\r
+</div>\r
+</div>\r
+</wicket:fragment>\r
+\r
+<!--ACTIVITY -->\r
+<wicket:fragment wicket:id="activityFragment"> \r
+       <table class="table tickets">\r
+               <thead>\r
+                       <tr>\r
+                               <th><wicket:message key="gb.author"></wicket:message></th>\r
+                               <th colspan='3'><wicket:message key="gb.action"></wicket:message></th>          \r
+                               <th style="text-align: right;"><wicket:message key="gb.date"></wicket:message></th>\r
+                       </tr>\r
+               </thead>\r
+               <tbody>\r
+                       <tr wicket:id="event">\r
+                               <td><span class="hidden-phone" wicket:id="changeAvatar">[avatar]</span> <span class="attribution-emphasize" wicket:id="changeAuthor">[author]</span></td>\r
+                               <td>\r
+                                       <span class="attribution-txt"><span wicket:id="what">[what happened]</span></span>\r
+                                       <div wicket:id="fields"></div>\r
+                               </td>\r
+                               <td style="text-align:right;">\r
+                                       <span wicket:id="patchsetType">[revision type]</span>                                   \r
+                               </td>\r
+                               <td><span class="hidden-phone hidden-tablet aui-lozenge aui-lozenge-subtle" wicket:id="patchsetRevision">[R1]</span>\r
+                                       <span class="hidden-tablet hidden-phone" style="padding-left:15px;"><span wicket:id="patchsetDiffStat"></span></span>\r
+                               </td>                   \r
+                               <td style="text-align:right;"><span class="attribution-date" wicket:id="changeDate">[patch date]</span></td>    \r
+                       </tr>\r
+               </tbody>\r
+       </table>\r
+</wicket:fragment>\r
+\r
+\r
+<!-- REVIEW CONTROLS -->\r
+<wicket:fragment wicket:id="reviewControlsFragment">\r
+       <div class="btn-group pull-right hidden-phone hidden-tablet">\r
+               <a class="btn btn-mini dropdown-toggle" data-toggle="dropdown" href="#">\r
+                       <wicket:message key="gb.review"></wicket:message> <span class="caret"></span>\r
+               </a>\r
+               <ul class="dropdown-menu">\r
+                       <li><span><a wicket:id="approveLink">approve</a></span></li>\r
+                       <li><span><a wicket:id="looksGoodLink">looks good</a></span></li>\r
+                       <li><span><a wicket:id="needsImprovementLink">needs improvement</a></span></li>\r
+                       <li><span><a wicket:id="vetoLink">veto</a></span></li>\r
+               </ul>\r
+       </div>\r
+</wicket:fragment>\r
+\r
+\r
+<!-- MERGEABLE PATCHSET FRAGMENT -->\r
+<wicket:fragment wicket:id="mergeableFragment">\r
+       <div class="alert alert-success submit-info" style="padding:4px;">\r
+               <div class="merge-panel" style="border: 1px solid rgba(70, 136, 70, 0.5);">\r
+                       <div class="pull-right" style="padding-top:5px;">\r
+                               <a class="btn btn-success" wicket:id="mergeButton"></a>\r
+                       </div>\r
+                       <h4><i class="fa fa-check-circle"></i> <span wicket:id="mergeTitle"></span></h4>\r
+                       <div wicket:id="mergeMore"></div>\r
+               </div>\r
+       </div>\r
+</wicket:fragment>\r
+\r
+\r
+<!-- COMMAND LINE MERGE INSTRUCTIONS -->\r
+<wicket:fragment wicket:id="commandlineMergeFragment">\r
+       <div class="accordion" id="accordionInstructions" style="margin: 0px;">\r
+               <span wicket:id="instructions"></span>\r
+               <a wicket:message="title:gb.showHideDetails" data-toggle="collapse" data-parent="#accordionInstructions" href="#bodyInstructions"><i class="fa fa-toggle-down"></i></a>\r
+       </div>\r
+\r
+       <div id="bodyInstructions" class="ticket-text accordion-body collapse" style="clear:both;">\r
+               <hr/>\r
+               <h4><wicket:message key="gb.mergingViaCommandLine"></wicket:message></h4>       \r
+               <p><wicket:message key="gb.mergingViaCommandLineNote"></wicket:message></p>\r
+               \r
+               <h4>Git</h4>\r
+               <p class="step">\r
+                       <b><span wicket:id="mergeStep1"></span>:</b> <wicket:message key="gb.mergeStep1"></wicket:message> <span wicket:id="mergeCopyStep1"></span>\r
+               </p>\r
+               <pre wicket:id="mergePreStep1" class="gitcommand"></pre>\r
+               <p class="step">\r
+                       <b><span wicket:id="mergeStep2"></span>:</b> <wicket:message key="gb.mergeStep2"></wicket:message> <span wicket:id="mergeCopyStep2"></span>\r
+               </p>\r
+               <pre wicket:id="mergePreStep2" class="gitcommand"></pre>\r
+               <p class="step">\r
+                       <b><span wicket:id="mergeStep3"></span>:</b> <wicket:message key="gb.mergeStep3"></wicket:message> <span wicket:id="mergeCopyStep3"></span>\r
+               </p>\r
+               <pre wicket:id="mergePreStep3" class="gitcommand"></pre>\r
+               \r
+               <hr/>\r
+               <h4>Barnum <small><wicket:message key="gb.ptDescription"></wicket:message> (<a href="#ptModal" role="button" data-toggle="modal"><wicket:message key="gb.about"></wicket:message></a>)</small></h4>\r
+               <p class="step">\r
+                 <wicket:message key="gb.ptMerge"></wicket:message> <span wicket:id="ptMergeCopyStep"></span>\r
+           </p>\r
+           <pre wicket:id="ptMergeStep" class="gitcommand"></pre>\r
+       </div>          \r
+</wicket:fragment>\r
+\r
+\r
+<!-- ALREADY MERGED FRAGMENT -->\r
+<wicket:fragment wicket:id="alreadyMergedFragment">\r
+       <div class="alert alert-success submit-info" style="padding:4px;">\r
+               <div class="merge-panel" style="border: 1px solid rgba(70, 136, 70, 0.5);">\r
+                       <h4><i class="fa fa-check-circle"></i> <span wicket:id="mergeTitle"></span></h4>\r
+               </div>\r
+       </div>\r
+</wicket:fragment>\r
+\r
+\r
+<!-- NOT-MERGEABLE FRAGMENT -->\r
+<wicket:fragment wicket:id="notMergeableFragment">\r
+       <div class="alert alert-error submit-info" style="padding:4px;">\r
+               <div class="merge-panel" style="border: 1px solid rgba(136, 70, 70, 0.5);">\r
+                       <h4><i class="fa fa-exclamation-triangle"></i> <span wicket:id="mergeTitle"></span></h4>\r
+                       <div wicket:id="mergeMore"></div>\r
+               </div>\r
+       </div>\r
+</wicket:fragment>\r
+\r
+\r
+<!-- VETOED PATCHSET FRAGMENT -->\r
+<wicket:fragment wicket:id="vetoedFragment">\r
+       <div class="alert alert-error submit-info" style="padding:4px;">\r
+               <div class="merge-panel" style="border: 1px solid rgba(136, 70, 70, 0.5);">\r
+                       <h4><i class="fa fa-exclamation-circle"></i> <span wicket:id="mergeTitle"></span></h4>\r
+                       <wicket:message key="gb.patchsetVetoedMore"></wicket:message>\r
+               </div>\r
+       </div>\r
+</wicket:fragment>\r
+\r
+\r
+<!-- NOT APPROVED PATCHSET FRAGMENT -->\r
+<wicket:fragment wicket:id="notApprovedFragment">\r
+       <div class="alert alert-info submit-info" style="padding:4px;">\r
+               <div class="merge-panel" style="border: 1px solid rgba(0, 70, 200, 0.5);">\r
+                       <h4><i class="fa fa-minus-circle"></i> <span wicket:id="mergeTitle"></span></h4>\r
+                       <div wicket:id="mergeMore"></div>                       \r
+               </div>\r
+       </div>\r
+</wicket:fragment>\r
+\r
+\r
+<!-- Plain JavaScript manual copy & paste -->\r
+<wicket:fragment wicket:id="jsPanel">\r
+       <span style="vertical-align:baseline;">\r
+               <img wicket:id="copyIcon" wicket:message="title:gb.copyToClipboard"></img>\r
+       </span>\r
+</wicket:fragment>\r
+    \r
+\r
+<!-- flash-based button-press copy & paste -->\r
+<wicket:fragment wicket:id="clippyPanel">\r
+       <object wicket:message="title:gb.copyToClipboard" style="vertical-align:middle;"\r
+                       wicket:id="clippy"\r
+                       width="14" \r
+                       height="14"\r
+                       bgcolor="#ffffff" \r
+                       quality="high"\r
+                       wmode="transparent"\r
+                       scale="noscale"\r
+                       allowScriptAccess="always"></object>\r
+</wicket:fragment>\r
+       \r
+</wicket:extend>    \r
+</body>\r
+</html>
\ No newline at end of file
diff --git a/src/main/java/com/gitblit/wicket/pages/TicketPage.java b/src/main/java/com/gitblit/wicket/pages/TicketPage.java
new file mode 100644 (file)
index 0000000..0d60ec2
--- /dev/null
@@ -0,0 +1,1527 @@
+/*\r
+ * Copyright 2014 gitblit.com.\r
+ *\r
+ * Licensed under the Apache License, Version 2.0 (the "License");\r
+ * you may not use this file except in compliance with the License.\r
+ * You may obtain a copy of the License at\r
+ *\r
+ *     http://www.apache.org/licenses/LICENSE-2.0\r
+ *\r
+ * Unless required by applicable law or agreed to in writing, software\r
+ * distributed under the License is distributed on an "AS IS" BASIS,\r
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r
+ * See the License for the specific language governing permissions and\r
+ * limitations under the License.\r
+ */\r
+package com.gitblit.wicket.pages;\r
+\r
+import java.text.DateFormat;\r
+import java.text.MessageFormat;\r
+import java.text.SimpleDateFormat;\r
+import java.util.ArrayList;\r
+import java.util.Arrays;\r
+import java.util.Calendar;\r
+import java.util.Collections;\r
+import java.util.Date;\r
+import java.util.List;\r
+import java.util.Map;\r
+import java.util.Set;\r
+import java.util.TimeZone;\r
+import java.util.TreeSet;\r
+\r
+import javax.servlet.http.HttpServletRequest;\r
+\r
+import org.apache.wicket.AttributeModifier;\r
+import org.apache.wicket.Component;\r
+import org.apache.wicket.MarkupContainer;\r
+import org.apache.wicket.PageParameters;\r
+import org.apache.wicket.RestartResponseException;\r
+import org.apache.wicket.ajax.AjaxRequestTarget;\r
+import org.apache.wicket.behavior.IBehavior;\r
+import org.apache.wicket.behavior.SimpleAttributeModifier;\r
+import org.apache.wicket.markup.html.basic.Label;\r
+import org.apache.wicket.markup.html.image.ContextImage;\r
+import org.apache.wicket.markup.html.link.BookmarkablePageLink;\r
+import org.apache.wicket.markup.html.link.ExternalLink;\r
+import org.apache.wicket.markup.html.panel.Fragment;\r
+import org.apache.wicket.markup.repeater.Item;\r
+import org.apache.wicket.markup.repeater.data.DataView;\r
+import org.apache.wicket.markup.repeater.data.ListDataProvider;\r
+import org.apache.wicket.model.Model;\r
+import org.apache.wicket.protocol.http.WebRequest;\r
+import org.eclipse.jgit.diff.DiffEntry.ChangeType;\r
+import org.eclipse.jgit.lib.PersonIdent;\r
+import org.eclipse.jgit.lib.Ref;\r
+import org.eclipse.jgit.lib.Repository;\r
+import org.eclipse.jgit.revwalk.RevCommit;\r
+import org.eclipse.jgit.transport.URIish;\r
+\r
+import com.gitblit.Constants;\r
+import com.gitblit.Constants.AccessPermission;\r
+import com.gitblit.Keys;\r
+import com.gitblit.git.PatchsetCommand;\r
+import com.gitblit.git.PatchsetReceivePack;\r
+import com.gitblit.models.PathModel.PathChangeModel;\r
+import com.gitblit.models.RegistrantAccessPermission;\r
+import com.gitblit.models.RepositoryModel;\r
+import com.gitblit.models.SubmoduleModel;\r
+import com.gitblit.models.TicketModel;\r
+import com.gitblit.models.TicketModel.Change;\r
+import com.gitblit.models.TicketModel.CommentSource;\r
+import com.gitblit.models.TicketModel.Field;\r
+import com.gitblit.models.TicketModel.Patchset;\r
+import com.gitblit.models.TicketModel.PatchsetType;\r
+import com.gitblit.models.TicketModel.Review;\r
+import com.gitblit.models.TicketModel.Score;\r
+import com.gitblit.models.TicketModel.Status;\r
+import com.gitblit.models.UserModel;\r
+import com.gitblit.tickets.TicketIndexer.Lucene;\r
+import com.gitblit.tickets.TicketLabel;\r
+import com.gitblit.tickets.TicketMilestone;\r
+import com.gitblit.tickets.TicketResponsible;\r
+import com.gitblit.utils.JGitUtils;\r
+import com.gitblit.utils.JGitUtils.MergeStatus;\r
+import com.gitblit.utils.MarkdownUtils;\r
+import com.gitblit.utils.StringUtils;\r
+import com.gitblit.utils.TimeUtils;\r
+import com.gitblit.wicket.GitBlitWebSession;\r
+import com.gitblit.wicket.WicketUtils;\r
+import com.gitblit.wicket.panels.BasePanel.JavascriptTextPrompt;\r
+import com.gitblit.wicket.panels.CommentPanel;\r
+import com.gitblit.wicket.panels.DiffStatPanel;\r
+import com.gitblit.wicket.panels.GravatarImage;\r
+import com.gitblit.wicket.panels.IconAjaxLink;\r
+import com.gitblit.wicket.panels.LinkPanel;\r
+import com.gitblit.wicket.panels.ShockWaveComponent;\r
+import com.gitblit.wicket.panels.SimpleAjaxLink;\r
+\r
+/**\r
+ * The ticket page handles viewing and updating a ticket.\r
+ *\r
+ * @author James Moger\r
+ *\r
+ */\r
+public class TicketPage extends TicketBasePage {\r
+\r
+       static final String NIL = "<nil>";\r
+\r
+       static final String ESC_NIL = StringUtils.escapeForHtml(NIL,  false);\r
+\r
+       final int avatarWidth = 40;\r
+\r
+       final TicketModel ticket;\r
+\r
+       public TicketPage(PageParameters params) {\r
+               super(params);\r
+\r
+               final UserModel user = GitBlitWebSession.get().getUser() == null ? UserModel.ANONYMOUS : GitBlitWebSession.get().getUser();\r
+               final boolean isAuthenticated = !UserModel.ANONYMOUS.equals(user) && user.isAuthenticated;\r
+               final RepositoryModel repository = getRepositoryModel();\r
+               final String id = WicketUtils.getObject(params);\r
+               long ticketId = Long.parseLong(id);\r
+               ticket = app().tickets().getTicket(repository, ticketId);\r
+\r
+               if (ticket == null) {\r
+                       // ticket not found\r
+                       throw new RestartResponseException(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));\r
+               }\r
+\r
+               final List<Change> revisions = new ArrayList<Change>();\r
+               List<Change> comments = new ArrayList<Change>();\r
+               List<Change> statusChanges = new ArrayList<Change>();\r
+               List<Change> discussion = new ArrayList<Change>();\r
+               for (Change change : ticket.changes) {\r
+                       if (change.hasComment() || (change.isStatusChange() && (change.getStatus() != Status.New))) {\r
+                               discussion.add(change);\r
+                       }\r
+                       if (change.hasComment()) {\r
+                               comments.add(change);\r
+                       }\r
+                       if (change.hasPatchset()) {\r
+                               revisions.add(change);\r
+                       }\r
+                       if (change.isStatusChange() && !change.hasPatchset()) {\r
+                               statusChanges.add(change);\r
+                       }\r
+               }\r
+\r
+               final Change currentRevision = revisions.isEmpty() ? null : revisions.get(revisions.size() - 1);\r
+               final Patchset currentPatchset = ticket.getCurrentPatchset();\r
+\r
+               /*\r
+                * TICKET HEADER\r
+                */\r
+               String href = urlFor(TicketsPage.class, params).toString();\r
+               add(new ExternalLink("ticketNumber", href, "#" + ticket.number));\r
+               Label headerStatus = new Label("headerStatus", ticket.status.toString());\r
+               WicketUtils.setCssClass(headerStatus, getLozengeClass(ticket.status, false));\r
+               add(headerStatus);\r
+               add(new Label("ticketTitle", ticket.title));\r
+               if (currentPatchset == null) {\r
+                       add(new Label("diffstat").setVisible(false));\r
+               } else {\r
+                       // calculate the current diffstat of the patchset\r
+                       add(new DiffStatPanel("diffstat", ticket.insertions, ticket.deletions));\r
+               }\r
+\r
+\r
+               /*\r
+                * TAB TITLES\r
+                */\r
+               add(new Label("commentCount", "" + comments.size()).setVisible(!comments.isEmpty()));\r
+               add(new Label("commitCount", "" + (currentPatchset == null ? 0 : currentPatchset.commits)).setVisible(currentPatchset != null));\r
+\r
+\r
+               /*\r
+                * TICKET AUTHOR and DATE (DISCUSSION TAB)\r
+                */\r
+               UserModel createdBy = app().users().getUserModel(ticket.createdBy);\r
+               if (createdBy == null) {\r
+                       add(new Label("whoCreated", ticket.createdBy));\r
+               } else {\r
+                       add(new LinkPanel("whoCreated", null, createdBy.getDisplayName(),\r
+                                       UserPage.class, WicketUtils.newUsernameParameter(createdBy.username)));\r
+               }\r
+\r
+               if (ticket.isProposal()) {\r
+                       // clearly indicate this is a change ticket\r
+                       add(new Label("creationMessage", getString("gb.proposedThisChange")));\r
+               } else {\r
+                       // standard ticket\r
+                       add(new Label("creationMessage", getString("gb.createdThisTicket")));\r
+               }\r
+\r
+               String dateFormat = app().settings().getString(Keys.web.datestampLongFormat, "EEEE, MMMM d, yyyy");\r
+               String timestampFormat = app().settings().getString(Keys.web.datetimestampLongFormat, "EEEE, MMMM d, yyyy");\r
+               final TimeZone timezone = getTimeZone();\r
+               final DateFormat df = new SimpleDateFormat(dateFormat);\r
+               df.setTimeZone(timezone);\r
+               final DateFormat tsf = new SimpleDateFormat(timestampFormat);\r
+               tsf.setTimeZone(timezone);\r
+               final Calendar cal = Calendar.getInstance(timezone);\r
+\r
+               String fuzzydate;\r
+               TimeUtils tu = getTimeUtils();\r
+               Date createdDate = ticket.created;\r
+               if (TimeUtils.isToday(createdDate, timezone)) {\r
+                       fuzzydate = tu.today();\r
+               } else if (TimeUtils.isYesterday(createdDate, timezone)) {\r
+                       fuzzydate = tu.yesterday();\r
+               } else {\r
+                       // calculate a fuzzy time ago date\r
+               cal.setTime(createdDate);\r
+               cal.set(Calendar.HOUR_OF_DAY, 0);\r
+               cal.set(Calendar.MINUTE, 0);\r
+               cal.set(Calendar.SECOND, 0);\r
+               cal.set(Calendar.MILLISECOND, 0);\r
+               createdDate = cal.getTime();\r
+                       fuzzydate = getTimeUtils().timeAgo(createdDate);\r
+               }\r
+               Label when = new Label("whenCreated", fuzzydate + ", " + df.format(createdDate));\r
+               WicketUtils.setHtmlTooltip(when, tsf.format(ticket.created));\r
+               add(when);\r
+\r
+               String exportHref = urlFor(ExportTicketPage.class, params).toString();\r
+               add(new ExternalLink("exportJson", exportHref, "json"));\r
+\r
+\r
+               /*\r
+                * RESPONSIBLE (DISCUSSION TAB)\r
+                */\r
+               if (StringUtils.isEmpty(ticket.responsible)) {\r
+                       add(new Label("responsible"));\r
+               } else {\r
+                       UserModel responsible = app().users().getUserModel(ticket.responsible);\r
+                       if (responsible == null) {\r
+                               add(new Label("responsible", ticket.responsible));\r
+                       } else {\r
+                               add(new LinkPanel("responsible", null, responsible.getDisplayName(),\r
+                                               UserPage.class, WicketUtils.newUsernameParameter(responsible.username)));\r
+                       }\r
+               }\r
+\r
+               /*\r
+                * MILESTONE PROGRESS (DISCUSSION TAB)\r
+                */\r
+               if (StringUtils.isEmpty(ticket.milestone)) {\r
+                       add(new Label("milestone"));\r
+               } else {\r
+                       // link to milestone query\r
+                       TicketMilestone milestone = app().tickets().getMilestone(repository, ticket.milestone);\r
+                       PageParameters milestoneParameters = new PageParameters();\r
+                       milestoneParameters.put("r", repositoryName);\r
+                       milestoneParameters.put(Lucene.milestone.name(), ticket.milestone);\r
+                       int progress = 0;\r
+                       int open = 0;\r
+                       int closed = 0;\r
+                       if (milestone != null) {\r
+                               progress = milestone.getProgress();\r
+                               open = milestone.getOpenTickets();\r
+                               closed = milestone.getClosedTickets();\r
+                       }\r
+\r
+                       Fragment milestoneProgress = new Fragment("milestone", "milestoneProgressFragment", this);\r
+                       milestoneProgress.add(new LinkPanel("link", null, ticket.milestone, TicketsPage.class, milestoneParameters));\r
+                       Label label = new Label("progress");\r
+                       WicketUtils.setCssStyle(label, "width:" + progress + "%;");\r
+                       milestoneProgress.add(label);\r
+                       WicketUtils.setHtmlTooltip(milestoneProgress, MessageFormat.format("{0} open, {1} closed", open, closed));\r
+                       add(milestoneProgress);\r
+               }\r
+\r
+\r
+               /*\r
+                * TICKET DESCRIPTION (DISCUSSION TAB)\r
+                */\r
+               String desc;\r
+               if (StringUtils.isEmpty(ticket.body)) {\r
+                       desc = getString("gb.noDescriptionGiven");\r
+               } else {\r
+                       desc = MarkdownUtils.transformGFM(app().settings(), ticket.body, ticket.repository);\r
+               }\r
+               add(new Label("ticketDescription", desc).setEscapeModelStrings(false));\r
+\r
+\r
+               /*\r
+                * PARTICIPANTS (DISCUSSION TAB)\r
+                */\r
+               if (app().settings().getBoolean(Keys.web.allowGravatar, true)) {\r
+                       // gravatar allowed\r
+                       List<String> participants = ticket.getParticipants();\r
+                       add(new Label("participantsLabel", MessageFormat.format(getString(participants.size() > 1 ? "gb.nParticipants" : "gb.oneParticipant"),\r
+                                       "<b>" + participants.size() + "</b>")).setEscapeModelStrings(false));\r
+                       ListDataProvider<String> participantsDp = new ListDataProvider<String>(participants);\r
+                       DataView<String> participantsView = new DataView<String>("participants", participantsDp) {\r
+                               private static final long serialVersionUID = 1L;\r
+\r
+                               @Override\r
+                               public void populateItem(final Item<String> item) {\r
+                                       String username = item.getModelObject();\r
+                                       UserModel user = app().users().getUserModel(username);\r
+                                       if (user == null) {\r
+                                               user = new UserModel(username);\r
+                                       }\r
+                                       item.add(new GravatarImage("participant", user.getDisplayName(),\r
+                                                       user.emailAddress, null, 25, true));\r
+                               }\r
+                       };\r
+                       add(participantsView);\r
+               } else {\r
+                       // gravatar prohibited\r
+                       add(new Label("participantsLabel").setVisible(false));\r
+                       add(new Label("participants").setVisible(false));\r
+               }\r
+\r
+\r
+               /*\r
+                * LARGE STATUS INDICATOR WITH ICON (DISCUSSION TAB->SIDE BAR)\r
+                */\r
+               Fragment ticketStatus = new Fragment("ticketStatus", "ticketStatusFragment", this);\r
+               Label ticketIcon = getStateIcon("ticketIcon", ticket);\r
+               ticketStatus.add(ticketIcon);\r
+               ticketStatus.add(new Label("ticketStatus", ticket.status.toString()));\r
+               WicketUtils.setCssClass(ticketStatus, getLozengeClass(ticket.status, false));\r
+               add(ticketStatus);\r
+\r
+\r
+               /*\r
+                * UPDATE FORM (DISCUSSION TAB)\r
+                */\r
+               if (isAuthenticated && app().tickets().isAcceptingTicketUpdates(repository)) {\r
+                       Fragment controls = new Fragment("controls", "controlsFragment", this);\r
+\r
+\r
+                       /*\r
+                        * STATUS\r
+                        */\r
+                       List<Status> choices = new ArrayList<Status>();\r
+                       if (ticket.isProposal()) {\r
+                               choices.addAll(Arrays.asList(TicketModel.Status.proposalWorkflow));\r
+                       } else if (ticket.isBug()) {\r
+                               choices.addAll(Arrays.asList(TicketModel.Status.bugWorkflow));\r
+                       } else {\r
+                               choices.addAll(Arrays.asList(TicketModel.Status.requestWorkflow));\r
+                       }\r
+                       choices.remove(ticket.status);\r
+\r
+                       ListDataProvider<Status> workflowDp = new ListDataProvider<Status>(choices);\r
+                       DataView<Status> statusView = new DataView<Status>("newStatus", workflowDp) {\r
+                               private static final long serialVersionUID = 1L;\r
+\r
+                               @Override\r
+                               public void populateItem(final Item<Status> item) {\r
+                                       SimpleAjaxLink<Status> link = new SimpleAjaxLink<Status>("link", item.getModel()) {\r
+\r
+                                               private static final long serialVersionUID = 1L;\r
+\r
+                                               @Override\r
+                                               public void onClick(AjaxRequestTarget target) {\r
+                                                       Status status = getModel().getObject();\r
+                                                       Change change = new Change(user.username);\r
+                                                       change.setField(Field.status, status);\r
+                                                       if (!ticket.isWatching(user.username)) {\r
+                                                               change.watch(user.username);\r
+                                                       }\r
+                                                       TicketModel update = app().tickets().updateTicket(repository, ticket.number, change);\r
+                                                       app().tickets().createNotifier().sendMailing(update);\r
+                                                       setResponsePage(TicketsPage.class, getPageParameters());\r
+                                               }\r
+                                       };\r
+                                       String css = getStatusClass(item.getModel().getObject());\r
+                                       WicketUtils.setCssClass(link, css);\r
+                                       item.add(link);\r
+                               }\r
+                       };\r
+                       controls.add(statusView);\r
+\r
+                       /*\r
+                        * RESPONSIBLE LIST\r
+                        */\r
+                       Set<String> userlist = new TreeSet<String>(ticket.getParticipants());\r
+                       for (RegistrantAccessPermission rp : app().repositories().getUserAccessPermissions(getRepositoryModel())) {\r
+                               if (rp.permission.atLeast(AccessPermission.PUSH) && !rp.isTeam()) {\r
+                                       userlist.add(rp.registrant);\r
+                               }\r
+                       }\r
+                       List<TicketResponsible> responsibles = new ArrayList<TicketResponsible>();\r
+                       if (!StringUtils.isEmpty(ticket.responsible)) {\r
+                               // exclude the current responsible\r
+                               userlist.remove(ticket.responsible);\r
+                       }\r
+                       for (String username : userlist) {\r
+                               UserModel u = app().users().getUserModel(username);\r
+                               if (u != null) {\r
+                                       responsibles.add(new TicketResponsible(u));\r
+                               }\r
+                       }\r
+                       Collections.sort(responsibles);\r
+                       responsibles.add(new TicketResponsible(ESC_NIL, "", ""));\r
+                       ListDataProvider<TicketResponsible> responsibleDp = new ListDataProvider<TicketResponsible>(responsibles);\r
+                       DataView<TicketResponsible> responsibleView = new DataView<TicketResponsible>("newResponsible", responsibleDp) {\r
+                               private static final long serialVersionUID = 1L;\r
+\r
+                               @Override\r
+                               public void populateItem(final Item<TicketResponsible> item) {\r
+                                       SimpleAjaxLink<TicketResponsible> link = new SimpleAjaxLink<TicketResponsible>("link", item.getModel()) {\r
+\r
+                                               private static final long serialVersionUID = 1L;\r
+\r
+                                               @Override\r
+                                               public void onClick(AjaxRequestTarget target) {\r
+                                                       TicketResponsible responsible = getModel().getObject();\r
+                                                       Change change = new Change(user.username);\r
+                                                       change.setField(Field.responsible, responsible.username);\r
+                                                       if (!StringUtils.isEmpty(responsible.username)) {\r
+                                                               if (!ticket.isWatching(responsible.username)) {\r
+                                                                       change.watch(responsible.username);\r
+                                                               }\r
+                                                       }\r
+                                                       if (!ticket.isWatching(user.username)) {\r
+                                                               change.watch(user.username);\r
+                                                       }\r
+                                                       TicketModel update = app().tickets().updateTicket(repository, ticket.number, change);\r
+                                                       app().tickets().createNotifier().sendMailing(update);\r
+                                                       setResponsePage(TicketsPage.class, getPageParameters());\r
+                                               }\r
+                                       };\r
+                                       item.add(link);\r
+                               }\r
+                       };\r
+                       controls.add(responsibleView);\r
+\r
+                       /*\r
+                        * MILESTONE LIST\r
+                        */\r
+                       List<TicketMilestone> milestones = app().tickets().getMilestones(repository, Status.Open);\r
+                       if (!StringUtils.isEmpty(ticket.milestone)) {\r
+                               for (TicketMilestone milestone : milestones) {\r
+                                       if (milestone.name.equals(ticket.milestone)) {\r
+                                               milestones.remove(milestone);\r
+                                               break;\r
+                                       }\r
+                               }\r
+                       }\r
+                       milestones.add(new TicketMilestone(ESC_NIL));\r
+                       ListDataProvider<TicketMilestone> milestoneDp = new ListDataProvider<TicketMilestone>(milestones);\r
+                       DataView<TicketMilestone> milestoneView = new DataView<TicketMilestone>("newMilestone", milestoneDp) {\r
+                               private static final long serialVersionUID = 1L;\r
+\r
+                               @Override\r
+                               public void populateItem(final Item<TicketMilestone> item) {\r
+                                       SimpleAjaxLink<TicketMilestone> link = new SimpleAjaxLink<TicketMilestone>("link", item.getModel()) {\r
+\r
+                                               private static final long serialVersionUID = 1L;\r
+\r
+                                               @Override\r
+                                               public void onClick(AjaxRequestTarget target) {\r
+                                                       TicketMilestone milestone = getModel().getObject();\r
+                                                       Change change = new Change(user.username);\r
+                                                       if (NIL.equals(milestone.name)) {\r
+                                                               change.setField(Field.milestone, "");\r
+                                                       } else {\r
+                                                               change.setField(Field.milestone, milestone.name);\r
+                                                       }\r
+                                                       if (!ticket.isWatching(user.username)) {\r
+                                                               change.watch(user.username);\r
+                                                       }\r
+                                                       TicketModel update = app().tickets().updateTicket(repository, ticket.number, change);\r
+                                                       app().tickets().createNotifier().sendMailing(update);\r
+                                                       setResponsePage(TicketsPage.class, getPageParameters());\r
+                                               }\r
+                                       };\r
+                                       item.add(link);\r
+                               }\r
+                       };\r
+                       controls.add(milestoneView);\r
+\r
+                       String editHref = urlFor(EditTicketPage.class, params).toString();\r
+                       controls.add(new ExternalLink("editLink", editHref, getString("gb.edit")));\r
+\r
+                       add(controls);\r
+               } else {\r
+                       add(new Label("controls").setVisible(false));\r
+               }\r
+\r
+\r
+               /*\r
+                * TICKET METADATA\r
+                */\r
+               add(new Label("ticketType", ticket.type.toString()));\r
+               if (StringUtils.isEmpty(ticket.topic)) {\r
+                       add(new Label("ticketTopic").setVisible(false));\r
+               } else {\r
+                       // process the topic using the bugtraq config to link things\r
+                       String topic = messageProcessor().processPlainCommitMessage(getRepository(), repositoryName, ticket.topic);\r
+                       add(new Label("ticketTopic", topic).setEscapeModelStrings(false));\r
+               }\r
+\r
+\r
+               /*\r
+                * VOTERS\r
+                */\r
+               List<String> voters = ticket.getVoters();\r
+               Label votersCount = new Label("votes", "" + voters.size());\r
+               if (voters.size() == 0) {\r
+                       WicketUtils.setCssClass(votersCount, "badge");\r
+               } else {\r
+                       WicketUtils.setCssClass(votersCount, "badge badge-info");\r
+               }\r
+               add(votersCount);\r
+               if (user.isAuthenticated) {\r
+                       Model<String> model;\r
+                       if (ticket.isVoter(user.username)) {\r
+                               model = Model.of(getString("gb.removeVote"));\r
+                       } else {\r
+                               model = Model.of(MessageFormat.format(getString("gb.vote"), ticket.type.toString()));\r
+                       }\r
+                       SimpleAjaxLink<String> link = new SimpleAjaxLink<String>("voteLink", model) {\r
+\r
+                               private static final long serialVersionUID = 1L;\r
+\r
+                               @Override\r
+                               public void onClick(AjaxRequestTarget target) {\r
+                                       Change change = new Change(user.username);\r
+                                       if (ticket.isVoter(user.username)) {\r
+                                               change.unvote(user.username);\r
+                                       } else {\r
+                                               change.vote(user.username);\r
+                                       }\r
+                                       app().tickets().updateTicket(repository, ticket.number, change);\r
+                                       setResponsePage(TicketsPage.class, getPageParameters());\r
+                               }\r
+                       };\r
+                       add(link);\r
+               } else {\r
+                       add(new Label("voteLink").setVisible(false));\r
+               }\r
+\r
+\r
+               /*\r
+                * WATCHERS\r
+                */\r
+               List<String> watchers = ticket.getWatchers();\r
+               Label watchersCount = new Label("watchers", "" + watchers.size());\r
+               if (watchers.size() == 0) {\r
+                       WicketUtils.setCssClass(watchersCount, "badge");\r
+               } else {\r
+                       WicketUtils.setCssClass(watchersCount, "badge badge-info");\r
+               }\r
+               add(watchersCount);\r
+               if (user.isAuthenticated) {\r
+                       Model<String> model;\r
+                       if (ticket.isWatching(user.username)) {\r
+                               model = Model.of(getString("gb.stopWatching"));\r
+                       } else {\r
+                               model = Model.of(MessageFormat.format(getString("gb.watch"), ticket.type.toString()));\r
+                       }\r
+                       SimpleAjaxLink<String> link = new SimpleAjaxLink<String>("watchLink", model) {\r
+\r
+                               private static final long serialVersionUID = 1L;\r
+\r
+                               @Override\r
+                               public void onClick(AjaxRequestTarget target) {\r
+                                       Change change = new Change(user.username);\r
+                                       if (ticket.isWatching(user.username)) {\r
+                                               change.unwatch(user.username);\r
+                                       } else {\r
+                                               change.watch(user.username);\r
+                                       }\r
+                                       app().tickets().updateTicket(repository, ticket.number, change);\r
+                                       setResponsePage(TicketsPage.class, getPageParameters());\r
+                               }\r
+                       };\r
+                       add(link);\r
+               } else {\r
+                       add(new Label("watchLink").setVisible(false));\r
+               }\r
+\r
+\r
+               /*\r
+                * TOPIC & LABELS (DISCUSSION TAB->SIDE BAR)\r
+                */\r
+               ListDataProvider<String> labelsDp = new ListDataProvider<String>(ticket.getLabels());\r
+               DataView<String> labelsView = new DataView<String>("labels", labelsDp) {\r
+                       private static final long serialVersionUID = 1L;\r
+\r
+                       @Override\r
+                       public void populateItem(final Item<String> item) {\r
+                               final String value = item.getModelObject();\r
+                               Label label = new Label("label", value);\r
+                               TicketLabel tLabel = app().tickets().getLabel(repository, value);\r
+                               String background = MessageFormat.format("background-color:{0};", tLabel.color);\r
+                               label.add(new SimpleAttributeModifier("style", background));\r
+                               item.add(label);\r
+                       }\r
+               };\r
+\r
+               add(labelsView);\r
+\r
+\r
+               /*\r
+                * COMMENTS & STATUS CHANGES (DISCUSSION TAB)\r
+                */\r
+               if (comments.size() == 0) {\r
+                       add(new Label("discussion").setVisible(false));\r
+               } else {\r
+                       Fragment discussionFragment = new Fragment("discussion", "discussionFragment", this);\r
+                       ListDataProvider<Change> discussionDp = new ListDataProvider<Change>(discussion);\r
+                       DataView<Change> discussionView = new DataView<Change>("discussion", discussionDp) {\r
+                               private static final long serialVersionUID = 1L;\r
+\r
+                               @Override\r
+                               public void populateItem(final Item<Change> item) {\r
+                                       final Change entry = item.getModelObject();\r
+                                       if (entry.isMerge()) {\r
+                                               /*\r
+                                                * MERGE\r
+                                                */\r
+                                               String resolvedBy = entry.getString(Field.mergeSha);\r
+\r
+                                               // identify the merged patch, it is likely the last\r
+                                               Patchset mergedPatch = null;\r
+                                               for (Change c : revisions) {\r
+                                                       if (c.patchset.tip.equals(resolvedBy)) {\r
+                                                               mergedPatch = c.patchset;\r
+                                                               break;\r
+                                                       }\r
+                                               }\r
+\r
+                                               String commitLink;\r
+                                               if (mergedPatch == null) {\r
+                                                       // shouldn't happen, but just-in-case\r
+                                                       int len = app().settings().getInteger(Keys.web.shortCommitIdLength, 6);\r
+                                                       commitLink = resolvedBy.substring(0, len);\r
+                                               } else {\r
+                                                       // expected result\r
+                                                       commitLink = mergedPatch.toString();\r
+                                               }\r
+\r
+                                               Fragment mergeFragment = new Fragment("entry", "mergeFragment", this);\r
+                                               mergeFragment.add(new LinkPanel("commitLink", null, commitLink,\r
+                                                               CommitPage.class, WicketUtils.newObjectParameter(repositoryName, resolvedBy)));\r
+                                               mergeFragment.add(new Label("toBranch", MessageFormat.format(getString("gb.toBranch"),\r
+                                                               "<b>" + ticket.mergeTo + "</b>")).setEscapeModelStrings(false));\r
+                                               addUserAttributions(mergeFragment, entry, 0);\r
+                                               addDateAttributions(mergeFragment, entry);\r
+\r
+                                               item.add(mergeFragment);\r
+                                       } else if (entry.isStatusChange()) {\r
+                                               /*\r
+                                                *  STATUS CHANGE\r
+                                                */\r
+                                               Fragment frag = new Fragment("entry", "statusFragment", this);\r
+                                               Label status = new Label("statusChange", entry.getStatus().toString());\r
+                                               String css = getLozengeClass(entry.getStatus(), false);\r
+                                               WicketUtils.setCssClass(status, css);\r
+                                               for (IBehavior b : status.getBehaviors()) {\r
+                                                       if (b instanceof SimpleAttributeModifier) {\r
+                                                               SimpleAttributeModifier sam = (SimpleAttributeModifier) b;\r
+                                                               if ("class".equals(sam.getAttribute())) {\r
+                                                                       status.add(new SimpleAttributeModifier("class", "status-change " + sam.getValue()));\r
+                                                                       break;\r
+                                                               }\r
+                                                       }\r
+                                               }\r
+                                               frag.add(status);\r
+                                               addUserAttributions(frag, entry, avatarWidth);\r
+                                               addDateAttributions(frag, entry);\r
+                                               item.add(frag);\r
+                                       } else {\r
+                                               /*\r
+                                                * COMMENT\r
+                                                */\r
+                                               String comment = MarkdownUtils.transformGFM(app().settings(), entry.comment.text, repositoryName);\r
+                                               Fragment frag = new Fragment("entry", "commentFragment", this);\r
+                                               Label commentIcon = new Label("commentIcon");\r
+                                               if (entry.comment.src == CommentSource.Email) {\r
+                                                       WicketUtils.setCssClass(commentIcon, "iconic-mail");\r
+                                               } else {\r
+                                                       WicketUtils.setCssClass(commentIcon, "iconic-comment-alt2-stroke");\r
+                                               }\r
+                                               frag.add(commentIcon);\r
+                                               frag.add(new Label("comment", comment).setEscapeModelStrings(false));\r
+                                               addUserAttributions(frag, entry, avatarWidth);\r
+                                               addDateAttributions(frag, entry);\r
+                                               item.add(frag);\r
+                                       }\r
+                               }\r
+                       };\r
+                       discussionFragment.add(discussionView);\r
+                       add(discussionFragment);\r
+               }\r
+\r
+               /*\r
+                * ADD COMMENT PANEL\r
+                */\r
+               if (UserModel.ANONYMOUS.equals(user)\r
+                               || !repository.isBare\r
+                               || repository.isFrozen\r
+                               || repository.isMirror) {\r
+\r
+                       // prohibit comments for anonymous users, local working copy repos,\r
+                       // frozen repos, and mirrors\r
+                       add(new Label("newComment").setVisible(false));\r
+               } else {\r
+                       // permit user to comment\r
+                       Fragment newComment = new Fragment("newComment", "newCommentFragment", this);\r
+                       GravatarImage img = new GravatarImage("newCommentAvatar", user.username, user.emailAddress,\r
+                                       "gravatar-round", avatarWidth, true);\r
+                       newComment.add(img);\r
+                       CommentPanel commentPanel = new CommentPanel("commentPanel", user, ticket, null, TicketsPage.class);\r
+                       commentPanel.setRepository(repositoryName);\r
+                       newComment.add(commentPanel);\r
+                       add(newComment);\r
+               }\r
+\r
+\r
+               /*\r
+                *  PATCHSET TAB\r
+                */\r
+               if (currentPatchset == null) {\r
+                       // no patchset yet, show propose fragment\r
+                       String repoUrl = getRepositoryUrl(user, repository);\r
+                       Fragment changeIdFrag = new Fragment("patchset", "proposeFragment", this);\r
+                       changeIdFrag.add(new Label("proposeInstructions", MarkdownUtils.transformMarkdown(getString("gb.proposeInstructions"))).setEscapeModelStrings(false));\r
+                       changeIdFrag.add(new Label("ptWorkflow", MessageFormat.format(getString("gb.proposeWith"), "Barnum")));\r
+                       changeIdFrag.add(new Label("ptWorkflowSteps", getProposeWorkflow("propose_pt.md", repoUrl, ticket.number)).setEscapeModelStrings(false));\r
+                       changeIdFrag.add(new Label("gitWorkflow", MessageFormat.format(getString("gb.proposeWith"), "Git")));\r
+                       changeIdFrag.add(new Label("gitWorkflowSteps", getProposeWorkflow("propose_git.md", repoUrl, ticket.number)).setEscapeModelStrings(false));\r
+                       add(changeIdFrag);\r
+               } else {\r
+                       // show current patchset\r
+                       Fragment patchsetFrag = new Fragment("patchset", "patchsetFragment", this);\r
+                       patchsetFrag.add(new Label("commitsInPatchset", MessageFormat.format(getString("gb.commitsInPatchsetN"), currentPatchset.number)));\r
+\r
+                       // current revision\r
+                       MarkupContainer panel = createPatchsetPanel("panel", repository, user);\r
+                       patchsetFrag.add(panel);\r
+                       addUserAttributions(patchsetFrag, currentRevision, avatarWidth);\r
+                       addUserAttributions(panel, currentRevision, 0);\r
+                       addDateAttributions(panel, currentRevision);\r
+\r
+                       // commits\r
+                       List<RevCommit> commits = JGitUtils.getRevLog(getRepository(), currentPatchset.base, currentPatchset.tip);\r
+                       ListDataProvider<RevCommit> commitsDp = new ListDataProvider<RevCommit>(commits);\r
+                       DataView<RevCommit> commitsView = new DataView<RevCommit>("commit", commitsDp) {\r
+                               private static final long serialVersionUID = 1L;\r
+\r
+                               @Override\r
+                               public void populateItem(final Item<RevCommit> item) {\r
+                                       RevCommit commit = item.getModelObject();\r
+                                       PersonIdent author = commit.getAuthorIdent();\r
+                                       item.add(new GravatarImage("authorAvatar", author.getName(), author.getEmailAddress(), null, 16, false));\r
+                                       item.add(new Label("author", commit.getAuthorIdent().getName()));\r
+                                       item.add(new LinkPanel("commitId", null, getShortObjectId(commit.getName()),\r
+                                                       CommitPage.class, WicketUtils.newObjectParameter(repositoryName, commit.getName()), true));\r
+                                       item.add(new LinkPanel("diff", "link", getString("gb.diff"), CommitDiffPage.class,\r
+                                                       WicketUtils.newObjectParameter(repositoryName, commit.getName()), true));\r
+                                       item.add(new Label("title", StringUtils.trimString(commit.getShortMessage(), Constants.LEN_SHORTLOG_REFS)));\r
+                                       item.add(WicketUtils.createDateLabel("commitDate", JGitUtils.getCommitDate(commit), GitBlitWebSession\r
+                                                       .get().getTimezone(), getTimeUtils(), false));\r
+                                       item.add(new DiffStatPanel("commitDiffStat", 0, 0, true));\r
+                               }\r
+                       };\r
+                       patchsetFrag.add(commitsView);\r
+                       add(patchsetFrag);\r
+               }\r
+\r
+\r
+               /*\r
+                * ACTIVITY TAB\r
+                */\r
+               Fragment revisionHistory = new Fragment("activity", "activityFragment", this);\r
+               List<Change> events = new ArrayList<Change>(ticket.changes);\r
+               Collections.sort(events);\r
+               Collections.reverse(events);\r
+               ListDataProvider<Change> eventsDp = new ListDataProvider<Change>(events);\r
+               DataView<Change> eventsView = new DataView<Change>("event", eventsDp) {\r
+                       private static final long serialVersionUID = 1L;\r
+\r
+                       @Override\r
+                       public void populateItem(final Item<Change> item) {\r
+                               Change event = item.getModelObject();\r
+\r
+                               addUserAttributions(item, event, 16);\r
+\r
+                               if (event.hasPatchset()) {\r
+                                       // patchset\r
+                                       Patchset patchset = event.patchset;\r
+                                       String what;\r
+                                       if (event.isStatusChange() && (Status.New == event.getStatus())) {\r
+                                               what = getString("gb.proposedThisChange");\r
+                                       } else if (patchset.rev == 1) {\r
+                                               what = MessageFormat.format(getString("gb.uploadedPatchsetN"), patchset.number);\r
+                                       } else {\r
+                                               if (patchset.added == 1) {\r
+                                                       what = getString("gb.addedOneCommit");\r
+                                               } else {\r
+                                                       what = MessageFormat.format(getString("gb.addedNCommits"), patchset.added);\r
+                                               }\r
+                                       }\r
+                                       item.add(new Label("what", what));\r
+\r
+                                       LinkPanel psr = new LinkPanel("patchsetRevision", null, patchset.number + "-" + patchset.rev,\r
+                                                       ComparePage.class, WicketUtils.newRangeParameter(repositoryName, patchset.parent == null ? patchset.base : patchset.parent, patchset.tip), true);\r
+                                       WicketUtils.setHtmlTooltip(psr, patchset.toString());\r
+                                       item.add(psr);\r
+                                       String typeCss = getPatchsetTypeCss(patchset.type);\r
+                                       Label typeLabel = new Label("patchsetType", patchset.type.toString());\r
+                                       if (typeCss == null) {\r
+                                               typeLabel.setVisible(false);\r
+                                       } else {\r
+                                               WicketUtils.setCssClass(typeLabel, typeCss);\r
+                                       }\r
+                                       item.add(typeLabel);\r
+\r
+                                       // show commit diffstat\r
+                                       item.add(new DiffStatPanel("patchsetDiffStat", patchset.insertions, patchset.deletions, patchset.rev > 1));\r
+                               } else if (event.hasComment()) {\r
+                                       // comment\r
+                                       item.add(new Label("what", getString("gb.commented")));\r
+                                       item.add(new Label("patchsetRevision").setVisible(false));\r
+                                       item.add(new Label("patchsetType").setVisible(false));\r
+                                       item.add(new Label("patchsetDiffStat").setVisible(false));\r
+                               } else if (event.hasReview()) {\r
+                                       // review\r
+                                       String score;\r
+                                       switch (event.review.score) {\r
+                                       case approved:\r
+                                               score = "<span style='color:darkGreen'>" + getScoreDescription(event.review.score) + "</span>";\r
+                                               break;\r
+                                       case vetoed:\r
+                                               score = "<span style='color:darkRed'>" + getScoreDescription(event.review.score) + "</span>";\r
+                                               break;\r
+                                       default:\r
+                                               score = getScoreDescription(event.review.score);\r
+                                       }\r
+                                       item.add(new Label("what", MessageFormat.format(getString("gb.reviewedPatchsetRev"),\r
+                                                       event.review.patchset, event.review.rev, score))\r
+                                                       .setEscapeModelStrings(false));\r
+                                       item.add(new Label("patchsetRevision").setVisible(false));\r
+                                       item.add(new Label("patchsetType").setVisible(false));\r
+                                       item.add(new Label("patchsetDiffStat").setVisible(false));\r
+                               } else {\r
+                                       // field change\r
+                                       item.add(new Label("patchsetRevision").setVisible(false));\r
+                                       item.add(new Label("patchsetType").setVisible(false));\r
+                                       item.add(new Label("patchsetDiffStat").setVisible(false));\r
+\r
+                                       String what = "";\r
+                                       if (event.isStatusChange()) {\r
+                                       switch (event.getStatus()) {\r
+                                       case New:\r
+                                               if (ticket.isProposal()) {\r
+                                                       what = getString("gb.proposedThisChange");\r
+                                               } else {\r
+                                                       what = getString("gb.createdThisTicket");\r
+                                               }\r
+                                               break;\r
+                                       default:\r
+                                               break;\r
+                                       }\r
+                                       }\r
+                                       item.add(new Label("what", what).setVisible(what.length() > 0));\r
+                               }\r
+\r
+                               addDateAttributions(item, event);\r
+\r
+                               if (event.hasFieldChanges()) {\r
+                                       StringBuilder sb = new StringBuilder();\r
+                                       sb.append("<table class=\"summary\"><tbody>");\r
+                                       for (Map.Entry<Field, String> entry : event.fields.entrySet()) {\r
+                                               String value;\r
+                                               switch (entry.getKey()) {\r
+                                                       case body:\r
+                                                               String body = entry.getValue();\r
+                                                               if (event.isStatusChange() && Status.New == event.getStatus() && StringUtils.isEmpty(body)) {\r
+                                                                       // ignore initial empty description\r
+                                                                       continue;\r
+                                                               }\r
+                                                               // trim body changes\r
+                                                               if (StringUtils.isEmpty(body)) {\r
+                                                                       value = "<i>" + ESC_NIL + "</i>";\r
+                                                               } else {\r
+                                                                       value = StringUtils.trimString(body, Constants.LEN_SHORTLOG_REFS);\r
+                                                               }\r
+                                                               break;\r
+                                                       case status:\r
+                                                               // special handling for status\r
+                                                               Status status = event.getStatus();\r
+                                                               String css = getLozengeClass(status, true);\r
+                                                               value = String.format("<span class=\"%1$s\">%2$s</span>", css, status.toString());\r
+                                                               break;\r
+                                                       default:\r
+                                                               value = StringUtils.isEmpty(entry.getValue()) ? ("<i>" + ESC_NIL + "</i>") : StringUtils.escapeForHtml(entry.getValue(), false);\r
+                                                               break;\r
+                                               }\r
+                                               sb.append("<tr><th style=\"width:70px;\">");\r
+                                               sb.append(entry.getKey().name());\r
+                                               sb.append("</th><td>");\r
+                                               sb.append(value);\r
+                                               sb.append("</td></tr>");\r
+                                       }\r
+                                       sb.append("</tbody></table>");\r
+                                       item.add(new Label("fields", sb.toString()).setEscapeModelStrings(false));\r
+                               } else {\r
+                                       item.add(new Label("fields").setVisible(false));\r
+                               }\r
+                       }\r
+               };\r
+               revisionHistory.add(eventsView);\r
+               add(revisionHistory);\r
+       }\r
+\r
+       protected void addUserAttributions(MarkupContainer container, Change entry, int avatarSize) {\r
+               UserModel commenter = app().users().getUserModel(entry.author);\r
+               if (commenter == null) {\r
+                       // unknown user\r
+                       container.add(new GravatarImage("changeAvatar", entry.author,\r
+                                       entry.author, null, avatarSize, false).setVisible(avatarSize > 0));\r
+                       container.add(new Label("changeAuthor", entry.author.toLowerCase()));\r
+               } else {\r
+                       // known user\r
+                       container.add(new GravatarImage("changeAvatar", commenter.getDisplayName(),\r
+                                       commenter.emailAddress, avatarSize > 24 ? "gravatar-round" : null,\r
+                                                       avatarSize, true).setVisible(avatarSize > 0));\r
+                       container.add(new LinkPanel("changeAuthor", null, commenter.getDisplayName(),\r
+                                       UserPage.class, WicketUtils.newUsernameParameter(commenter.username)));\r
+               }\r
+       }\r
+\r
+       protected void addDateAttributions(MarkupContainer container, Change entry) {\r
+               container.add(WicketUtils.createDateLabel("changeDate", entry.date, GitBlitWebSession\r
+                               .get().getTimezone(), getTimeUtils(), false));\r
+\r
+               // set the id attribute\r
+               if (entry.hasComment()) {\r
+                       container.setOutputMarkupId(true);\r
+                       container.add(new AttributeModifier("id", Model.of(entry.getId())));\r
+                       ExternalLink link = new ExternalLink("changeLink", "#" + entry.getId());\r
+                       container.add(link);\r
+               } else {\r
+                       container.add(new Label("changeLink").setVisible(false));\r
+               }\r
+       }\r
+\r
+       protected String getProposeWorkflow(String resource, String url, long ticketId) {\r
+               String md = readResource(resource);\r
+               md = md.replace("${url}", url);\r
+               md = md.replace("${repo}", StringUtils.getLastPathElement(StringUtils.stripDotGit(repositoryName)));\r
+               md = md.replace("${ticketId}", "" + ticketId);\r
+               md = md.replace("${patchset}", "" + 1);\r
+               md = md.replace("${reviewBranch}", Repository.shortenRefName(PatchsetCommand.getTicketBranch(ticketId)));\r
+               md = md.replace("${integrationBranch}", Repository.shortenRefName(getRepositoryModel().HEAD));\r
+               return MarkdownUtils.transformMarkdown(md);\r
+       }\r
+\r
+       protected Fragment createPatchsetPanel(String wicketId, RepositoryModel repository, UserModel user) {\r
+               final Patchset currentPatchset = ticket.getCurrentPatchset();\r
+               List<Patchset> patchsets = new ArrayList<Patchset>(ticket.getPatchsetRevisions(currentPatchset.number));\r
+               patchsets.remove(currentPatchset);\r
+               Collections.reverse(patchsets);\r
+\r
+               Fragment panel = new Fragment(wicketId, "collapsiblePatchsetFragment", this);\r
+\r
+               // patchset header\r
+               String ps = "<b>" + currentPatchset.number + "</b>";\r
+               if (currentPatchset.rev == 1) {\r
+                       panel.add(new Label("uploadedWhat", MessageFormat.format(getString("gb.uploadedPatchsetN"), ps)).setEscapeModelStrings(false));\r
+               } else {\r
+                       String rev = "<b>" + currentPatchset.rev + "</b>";\r
+                       panel.add(new Label("uploadedWhat", MessageFormat.format(getString("gb.uploadedPatchsetNRevisionN"), ps, rev)).setEscapeModelStrings(false));\r
+               }\r
+               panel.add(new LinkPanel("patchId", null, "rev " + currentPatchset.rev,\r
+                               CommitPage.class, WicketUtils.newObjectParameter(repositoryName, currentPatchset.tip), true));\r
+\r
+               // compare menu\r
+               panel.add(new LinkPanel("compareMergeBase", null, getString("gb.compareToMergeBase"),\r
+                               ComparePage.class, WicketUtils.newRangeParameter(repositoryName, currentPatchset.base, currentPatchset.tip), true));\r
+\r
+               ListDataProvider<Patchset> compareMenuDp = new ListDataProvider<Patchset>(patchsets);\r
+               DataView<Patchset> compareMenu = new DataView<Patchset>("comparePatch", compareMenuDp) {\r
+                       private static final long serialVersionUID = 1L;\r
+                       @Override\r
+                       public void populateItem(final Item<Patchset> item) {\r
+                               Patchset patchset = item.getModelObject();\r
+                               LinkPanel link = new LinkPanel("compareLink", null,\r
+                                               MessageFormat.format(getString("gb.compareToN"), patchset.number + "-" + patchset.rev),\r
+                                               ComparePage.class, WicketUtils.newRangeParameter(getRepositoryModel().name,\r
+                                                               patchset.tip, currentPatchset.tip), true);\r
+                               item.add(link);\r
+\r
+                       }\r
+               };\r
+               panel.add(compareMenu);\r
+\r
+\r
+               // reviews\r
+               List<Change> reviews = ticket.getReviews(currentPatchset);\r
+               ListDataProvider<Change> reviewsDp = new ListDataProvider<Change>(reviews);\r
+               DataView<Change> reviewsView = new DataView<Change>("reviews", reviewsDp) {\r
+                       private static final long serialVersionUID = 1L;\r
+\r
+                       @Override\r
+                       public void populateItem(final Item<Change> item) {\r
+                               Change change = item.getModelObject();\r
+                               final String username = change.author;\r
+                               UserModel user = app().users().getUserModel(username);\r
+                               if (user == null) {\r
+                                       item.add(new Label("reviewer", username));\r
+                               } else {\r
+                                       item.add(new LinkPanel("reviewer", null, user.getDisplayName(),\r
+                                                       UserPage.class, WicketUtils.newUsernameParameter(username)));\r
+                               }\r
+\r
+                               // indicate review score\r
+                               Review review = change.review;\r
+                               Label scoreLabel = new Label("score");\r
+                               String scoreClass = getScoreClass(review.score);\r
+                               String tooltip = getScoreDescription(review.score);\r
+                               WicketUtils.setCssClass(scoreLabel, scoreClass);\r
+                               if (!StringUtils.isEmpty(tooltip)) {\r
+                                       WicketUtils.setHtmlTooltip(scoreLabel, tooltip);\r
+                               }\r
+                               item.add(scoreLabel);\r
+                       }\r
+               };\r
+               panel.add(reviewsView);\r
+\r
+\r
+               if (ticket.isOpen() && user.canReviewPatchset(repository)) {\r
+                       // can only review open tickets\r
+                       Review myReview = null;\r
+                       for (Change change : ticket.getReviews(currentPatchset)) {\r
+                               if (change.author.equals(user.username)) {\r
+                                       myReview = change.review;\r
+                               }\r
+                       }\r
+\r
+                       // user can review, add review controls\r
+                       Fragment reviewControls = new Fragment("reviewControls", "reviewControlsFragment", this);\r
+\r
+                       // show "approve" button if no review OR not current score\r
+                       if (user.canApprovePatchset(repository) && (myReview == null || Score.approved != myReview.score)) {\r
+                               reviewControls.add(createReviewLink("approveLink", Score.approved));\r
+                       } else {\r
+                               reviewControls.add(new Label("approveLink").setVisible(false));\r
+                       }\r
+\r
+                       // show "looks good" button if no review OR not current score\r
+                       if (myReview == null || Score.looks_good != myReview.score) {\r
+                               reviewControls.add(createReviewLink("looksGoodLink", Score.looks_good));\r
+                       } else {\r
+                               reviewControls.add(new Label("looksGoodLink").setVisible(false));\r
+                       }\r
+\r
+                       // show "needs improvement" button if no review OR not current score\r
+                       if (myReview == null || Score.needs_improvement != myReview.score) {\r
+                               reviewControls.add(createReviewLink("needsImprovementLink", Score.needs_improvement));\r
+                       } else {\r
+                               reviewControls.add(new Label("needsImprovementLink").setVisible(false));\r
+                       }\r
+\r
+                       // show "veto" button if no review OR not current score\r
+                       if (user.canVetoPatchset(repository) && (myReview == null || Score.vetoed != myReview.score)) {\r
+                               reviewControls.add(createReviewLink("vetoLink", Score.vetoed));\r
+                       } else {\r
+                               reviewControls.add(new Label("vetoLink").setVisible(false));\r
+                       }\r
+                       panel.add(reviewControls);\r
+               } else {\r
+                       // user can not review\r
+                       panel.add(new Label("reviewControls").setVisible(false));\r
+               }\r
+\r
+               String insertions = MessageFormat.format("<span style=\"color:darkGreen;font-weight:bold;\">+{0}</span>", ticket.insertions);\r
+               String deletions = MessageFormat.format("<span style=\"color:darkRed;font-weight:bold;\">-{0}</span>", ticket.deletions);\r
+               panel.add(new Label("patchsetStat", MessageFormat.format(StringUtils.escapeForHtml(getString("gb.diffStat"), false),\r
+                               insertions, deletions)).setEscapeModelStrings(false));\r
+\r
+               // changed paths list\r
+               List<PathChangeModel> paths = JGitUtils.getFilesInRange(getRepository(), currentPatchset.base, currentPatchset.tip);\r
+               ListDataProvider<PathChangeModel> pathsDp = new ListDataProvider<PathChangeModel>(paths);\r
+               DataView<PathChangeModel> pathsView = new DataView<PathChangeModel>("changedPath", pathsDp) {\r
+                       private static final long serialVersionUID = 1L;\r
+                       int counter;\r
+\r
+                       @Override\r
+                       public void populateItem(final Item<PathChangeModel> item) {\r
+                               final PathChangeModel entry = item.getModelObject();\r
+                               Label changeType = new Label("changeType", "");\r
+                               WicketUtils.setChangeTypeCssClass(changeType, entry.changeType);\r
+                               setChangeTypeTooltip(changeType, entry.changeType);\r
+                               item.add(changeType);\r
+                               item.add(new DiffStatPanel("diffStat", entry.insertions, entry.deletions, true));\r
+\r
+                               boolean hasSubmodule = false;\r
+                               String submodulePath = null;\r
+                               if (entry.isTree()) {\r
+                                       // tree\r
+                                       item.add(new LinkPanel("pathName", null, entry.path, TreePage.class,\r
+                                                       WicketUtils\r
+                                                                       .newPathParameter(repositoryName, currentPatchset.tip, entry.path), true));\r
+                                       item.add(new Label("diffStat").setVisible(false));\r
+                               } else if (entry.isSubmodule()) {\r
+                                       // submodule\r
+                                       String submoduleId = entry.objectId;\r
+                                       SubmoduleModel submodule = getSubmodule(entry.path);\r
+                                       submodulePath = submodule.gitblitPath;\r
+                                       hasSubmodule = submodule.hasSubmodule;\r
+\r
+                                       item.add(new LinkPanel("pathName", "list", entry.path + " @ " +\r
+                                                       getShortObjectId(submoduleId), TreePage.class,\r
+                                                       WicketUtils.newPathParameter(submodulePath, submoduleId, ""), true).setEnabled(hasSubmodule));\r
+                                       item.add(new Label("diffStat").setVisible(false));\r
+                               } else {\r
+                                       // blob\r
+                                       String displayPath = entry.path;\r
+                                       String path = entry.path;\r
+                                       if (entry.isSymlink()) {\r
+                                               RevCommit commit = JGitUtils.getCommit(getRepository(), Constants.R_TICKETS_PATCHSETS + ticket.number);\r
+                                               path = JGitUtils.getStringContent(getRepository(), commit.getTree(), path);\r
+                                               displayPath = entry.path + " -> " + path;\r
+                                       }\r
+\r
+                                       if (entry.changeType.equals(ChangeType.ADD)) {\r
+                                               // add show view\r
+                                               item.add(new LinkPanel("pathName", "list", displayPath, BlobPage.class,\r
+                                                               WicketUtils.newPathParameter(repositoryName, currentPatchset.tip, path), true));\r
+                                       } else if (entry.changeType.equals(ChangeType.DELETE)) {\r
+                                               // delete, show label\r
+                                               item.add(new Label("pathName", displayPath));\r
+                                       } else {\r
+                                               // mod, show diff\r
+                                               item.add(new LinkPanel("pathName", "list", displayPath, BlobDiffPage.class,\r
+                                                               WicketUtils.newPathParameter(repositoryName, currentPatchset.tip, path), true));\r
+                                       }\r
+                               }\r
+\r
+                               // quick links\r
+                               if (entry.isSubmodule()) {\r
+                                       // submodule\r
+                                       item.add(setNewTarget(new BookmarkablePageLink<Void>("diff", BlobDiffPage.class, WicketUtils\r
+                                                       .newPathParameter(repositoryName, entry.commitId, entry.path)))\r
+                                                       .setEnabled(!entry.changeType.equals(ChangeType.ADD)));\r
+                                       item.add(new BookmarkablePageLink<Void>("view", CommitPage.class, WicketUtils\r
+                                                       .newObjectParameter(submodulePath, entry.objectId)).setEnabled(hasSubmodule));\r
+                               } else {\r
+                                       // tree or blob\r
+                                       item.add(setNewTarget(new BookmarkablePageLink<Void>("diff", BlobDiffPage.class, WicketUtils\r
+                                                       .newBlobDiffParameter(repositoryName, currentPatchset.base, currentPatchset.tip, entry.path)))\r
+                                                       .setEnabled(!entry.changeType.equals(ChangeType.ADD)\r
+                                                                       && !entry.changeType.equals(ChangeType.DELETE)));\r
+                                       item.add(setNewTarget(new BookmarkablePageLink<Void>("view", BlobPage.class, WicketUtils\r
+                                                       .newPathParameter(repositoryName, currentPatchset.tip, entry.path)))\r
+                                                       .setEnabled(!entry.changeType.equals(ChangeType.DELETE)));\r
+                               }\r
+\r
+                               WicketUtils.setAlternatingBackground(item, counter);\r
+                               counter++;\r
+                       }\r
+               };\r
+               panel.add(pathsView);\r
+\r
+               addPtReviewInstructions(user, repository, panel);\r
+               addGitReviewInstructions(user, repository, panel);\r
+               panel.add(createMergePanel(user, repository));\r
+\r
+               return panel;\r
+       }\r
+\r
+       protected IconAjaxLink<String> createReviewLink(String wicketId, final Score score) {\r
+               return new IconAjaxLink<String>(wicketId, getScoreClass(score), Model.of(getScoreDescription(score))) {\r
+\r
+                       private static final long serialVersionUID = 1L;\r
+\r
+                       @Override\r
+                       public void onClick(AjaxRequestTarget target) {\r
+                               review(score);\r
+                       }\r
+               };\r
+       }\r
+\r
+       protected String getScoreClass(Score score) {\r
+               switch (score) {\r
+               case vetoed:\r
+                       return "fa fa-exclamation-circle";\r
+               case needs_improvement:\r
+                       return "fa fa-thumbs-o-down";\r
+               case looks_good:\r
+                       return "fa fa-thumbs-o-up";\r
+               case approved:\r
+                       return "fa fa-check-circle";\r
+               case not_reviewed:\r
+               default:\r
+                       return "fa fa-minus-circle";\r
+               }\r
+       }\r
+\r
+       protected String getScoreDescription(Score score) {\r
+               String description;\r
+               switch (score) {\r
+               case vetoed:\r
+                       description = getString("gb.veto");\r
+                       break;\r
+               case needs_improvement:\r
+                       description = getString("gb.needsImprovement");\r
+                       break;\r
+               case looks_good:\r
+                       description = getString("gb.looksGood");\r
+                       break;\r
+               case approved:\r
+                       description = getString("gb.approve");\r
+                       break;\r
+               case not_reviewed:\r
+               default:\r
+                       description = getString("gb.hasNotReviewed");\r
+               }\r
+               return String.format("%1$s (%2$+d)", description, score.getValue());\r
+       }\r
+\r
+       protected void review(Score score) {\r
+               UserModel user = GitBlitWebSession.get().getUser();\r
+               Patchset ps = ticket.getCurrentPatchset();\r
+               Change change = new Change(user.username);\r
+               change.review(ps, score, !ticket.isReviewer(user.username));\r
+               if (!ticket.isWatching(user.username)) {\r
+                       change.watch(user.username);\r
+               }\r
+               TicketModel updatedTicket = app().tickets().updateTicket(getRepositoryModel(), ticket.number, change);\r
+               app().tickets().createNotifier().sendMailing(updatedTicket);\r
+               setResponsePage(TicketsPage.class, getPageParameters());\r
+       }\r
+\r
+       protected <X extends MarkupContainer> X setNewTarget(X x) {\r
+               x.add(new SimpleAttributeModifier("target", "_blank"));\r
+               return x;\r
+       }\r
+\r
+       protected void addGitReviewInstructions(UserModel user, RepositoryModel repository, MarkupContainer panel) {\r
+               String repoUrl = getRepositoryUrl(user, repository);\r
+\r
+               panel.add(new Label("gitStep1", MessageFormat.format(getString("gb.stepN"), 1)));\r
+               panel.add(new Label("gitStep2", MessageFormat.format(getString("gb.stepN"), 2)));\r
+\r
+               String ticketBranch  = Repository.shortenRefName(PatchsetCommand.getTicketBranch(ticket.number));\r
+               String reviewBranch = PatchsetCommand.getReviewBranch(ticket.number);\r
+\r
+               String step1 = MessageFormat.format("git fetch {0} {1}", repoUrl, ticketBranch);\r
+               String step2 = MessageFormat.format("git checkout -B {0} FETCH_HEAD", reviewBranch);\r
+\r
+               panel.add(new Label("gitPreStep1", step1));\r
+               panel.add(new Label("gitPreStep2", step2));\r
+\r
+               panel.add(createCopyFragment("gitCopyStep1", step1.replace("\n", " && ")));\r
+               panel.add(createCopyFragment("gitCopyStep2", step2.replace("\n", " && ")));\r
+       }\r
+\r
+       protected void addPtReviewInstructions(UserModel user, RepositoryModel repository, MarkupContainer panel) {\r
+               String step1 = MessageFormat.format("pt checkout {0,number,0}", ticket.number);\r
+               panel.add(new Label("ptPreStep", step1));\r
+               panel.add(createCopyFragment("ptCopyStep", step1));\r
+       }\r
+\r
+       /**\r
+        * Adds a merge panel for the patchset to the markup container.  The panel\r
+        * may just a message if the patchset can not be merged.\r
+        *\r
+        * @param c\r
+        * @param user\r
+        * @param repository\r
+        */\r
+       protected Component createMergePanel(UserModel user, RepositoryModel repository) {\r
+               Patchset patchset = ticket.getCurrentPatchset();\r
+               if (patchset == null) {\r
+                       // no patchset to merge\r
+                       return new Label("mergePanel");\r
+               }\r
+\r
+               boolean allowMerge;\r
+               if (repository.requireApproval) {\r
+                       // rpeository requires approval\r
+                       allowMerge = ticket.isOpen() && ticket.isApproved(patchset);\r
+               } else {\r
+                       // vetos are binding\r
+                       allowMerge = ticket.isOpen() && !ticket.isVetoed(patchset);\r
+               }\r
+\r
+               MergeStatus mergeStatus = JGitUtils.canMerge(getRepository(), patchset.tip, ticket.mergeTo);\r
+               if (allowMerge) {\r
+                       if (MergeStatus.MERGEABLE == mergeStatus) {\r
+                               // patchset can be cleanly merged to integration branch OR has already been merged\r
+                               Fragment mergePanel = new Fragment("mergePanel", "mergeableFragment", this);\r
+                               mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetMergeable"), ticket.mergeTo)));\r
+                               if (user.canPush(repository)) {\r
+                                       // user can merge locally\r
+                                       SimpleAjaxLink<String> mergeButton = new SimpleAjaxLink<String>("mergeButton", Model.of(getString("gb.merge"))) {\r
+\r
+                                               private static final long serialVersionUID = 1L;\r
+\r
+                                               @Override\r
+                                               public void onClick(AjaxRequestTarget target) {\r
+\r
+                                                       // ensure the patchset is still current AND not vetoed\r
+                                                       Patchset patchset = ticket.getCurrentPatchset();\r
+                                                       final TicketModel refreshedTicket = app().tickets().getTicket(getRepositoryModel(), ticket.number);\r
+                                                       if (patchset.equals(refreshedTicket.getCurrentPatchset())) {\r
+                                                               // patchset is current, check for recent veto\r
+                                                               if (!refreshedTicket.isVetoed(patchset)) {\r
+                                                                       // patchset is not vetoed\r
+\r
+                                                                       // execute the merge using the ticket service\r
+                                                                       app().tickets().exec(new Runnable() {\r
+                                                                               @Override\r
+                                                                               public void run() {\r
+                                                                                       PatchsetReceivePack rp = new PatchsetReceivePack(\r
+                                                                                                       app().gitblit(),\r
+                                                                                                       getRepository(),\r
+                                                                                                       getRepositoryModel(),\r
+                                                                                                       GitBlitWebSession.get().getUser());\r
+                                                                                       MergeStatus result = rp.merge(refreshedTicket);\r
+                                                                                       if (MergeStatus.MERGED == result) {\r
+                                                                                               // notify participants and watchers\r
+                                                                                               rp.sendAll();\r
+                                                                                       } else {\r
+                                                                                               // merge failure\r
+                                                                                               String msg = MessageFormat.format("Failed to merge ticket {0,number,0}: {1}", ticket.number, result.name());\r
+                                                                                               logger.error(msg);\r
+                                                                                               GitBlitWebSession.get().cacheErrorMessage(msg);\r
+                                                                                       }\r
+                                                                               }\r
+                                                                       });\r
+                                                               } else {\r
+                                                                       // vetoed patchset\r
+                                                                       String msg = MessageFormat.format("Can not merge ticket {0,number,0}, patchset {1,number,0} has been vetoed!",\r
+                                                                                       ticket.number, patchset.number);\r
+                                                                       GitBlitWebSession.get().cacheErrorMessage(msg);\r
+                                                                       logger.error(msg);\r
+                                                               }\r
+                                                       } else {\r
+                                                               // not current patchset\r
+                                                               String msg = MessageFormat.format("Can not merge ticket {0,number,0}, the patchset has been updated!", ticket.number);\r
+                                                               GitBlitWebSession.get().cacheErrorMessage(msg);\r
+                                                               logger.error(msg);\r
+                                                       }\r
+\r
+                                                       setResponsePage(TicketsPage.class, getPageParameters());\r
+                                               }\r
+                                       };\r
+                                       mergePanel.add(mergeButton);\r
+                                       Component instructions = getMergeInstructions(user, repository, "mergeMore", "gb.patchsetMergeableMore");\r
+                                       mergePanel.add(instructions);\r
+                               } else {\r
+                                       mergePanel.add(new Label("mergeButton").setVisible(false));\r
+                                       mergePanel.add(new Label("mergeMore").setVisible(false));\r
+                               }\r
+                               return mergePanel;\r
+                       } else if (MergeStatus.ALREADY_MERGED == mergeStatus) {\r
+                               // patchset already merged\r
+                               Fragment mergePanel = new Fragment("mergePanel", "alreadyMergedFragment", this);\r
+                               mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetAlreadyMerged"), ticket.mergeTo)));\r
+                               return mergePanel;\r
+                       } else {\r
+                               // patchset can not be cleanly merged\r
+                               Fragment mergePanel = new Fragment("mergePanel", "notMergeableFragment", this);\r
+                               mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetNotMergeable"), ticket.mergeTo)));\r
+                               if (user.canPush(repository)) {\r
+                                       // user can merge locally\r
+                                       Component instructions = getMergeInstructions(user, repository, "mergeMore", "gb.patchsetNotMergeableMore");\r
+                                       mergePanel.add(instructions);\r
+                               } else {\r
+                                       mergePanel.add(new Label("mergeMore").setVisible(false));\r
+                               }\r
+                               return mergePanel;\r
+                       }\r
+               } else {\r
+                       // merge not allowed\r
+                       if (MergeStatus.ALREADY_MERGED == mergeStatus) {\r
+                               // patchset already merged\r
+                               Fragment mergePanel = new Fragment("mergePanel", "alreadyMergedFragment", this);\r
+                               mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetAlreadyMerged"), ticket.mergeTo)));\r
+                               return mergePanel;\r
+                       } else if (ticket.isVetoed(patchset)) {\r
+                               // patchset has been vetoed\r
+                               Fragment mergePanel =  new Fragment("mergePanel", "vetoedFragment", this);\r
+                               mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetNotMergeable"), ticket.mergeTo)));\r
+                               return mergePanel;\r
+                       } else if (repository.requireApproval) {\r
+                               // patchset has been not been approved for merge\r
+                               Fragment mergePanel = new Fragment("mergePanel", "notApprovedFragment", this);\r
+                               mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetNotApproved"), ticket.mergeTo)));\r
+                               mergePanel.add(new Label("mergeMore", MessageFormat.format(getString("gb.patchsetNotApprovedMore"), ticket.mergeTo)));\r
+                               return mergePanel;\r
+                       } else {\r
+                               // other case\r
+                               return new Label("mergePanel");\r
+                       }\r
+               }\r
+       }\r
+\r
+       protected Component getMergeInstructions(UserModel user, RepositoryModel repository, String markupId, String infoKey) {\r
+               Fragment cmd = new Fragment(markupId, "commandlineMergeFragment", this);\r
+               cmd.add(new Label("instructions", MessageFormat.format(getString(infoKey), ticket.mergeTo)));\r
+               String repoUrl = getRepositoryUrl(user, repository);\r
+\r
+               // git instructions\r
+               cmd.add(new Label("mergeStep1", MessageFormat.format(getString("gb.stepN"), 1)));\r
+               cmd.add(new Label("mergeStep2", MessageFormat.format(getString("gb.stepN"), 2)));\r
+               cmd.add(new Label("mergeStep3", MessageFormat.format(getString("gb.stepN"), 3)));\r
+\r
+               String ticketBranch = Repository.shortenRefName(PatchsetCommand.getTicketBranch(ticket.number));\r
+               String reviewBranch = PatchsetCommand.getReviewBranch(ticket.number);\r
+\r
+               String step1 = MessageFormat.format("git checkout -B {0} {1}", reviewBranch, ticket.mergeTo);\r
+               String step2 = MessageFormat.format("git pull {0} {1}", repoUrl, ticketBranch);\r
+               String step3 = MessageFormat.format("git checkout {0}\ngit merge {1}\ngit push origin {0}", ticket.mergeTo, reviewBranch);\r
+\r
+               cmd.add(new Label("mergePreStep1", step1));\r
+               cmd.add(new Label("mergePreStep2", step2));\r
+               cmd.add(new Label("mergePreStep3", step3));\r
+\r
+               cmd.add(createCopyFragment("mergeCopyStep1", step1.replace("\n", " && ")));\r
+               cmd.add(createCopyFragment("mergeCopyStep2", step2.replace("\n", " && ")));\r
+               cmd.add(createCopyFragment("mergeCopyStep3", step3.replace("\n", " && ")));\r
+\r
+               // pt instructions\r
+               String ptStep = MessageFormat.format("pt pull {0,number,0}", ticket.number);\r
+               cmd.add(new Label("ptMergeStep", ptStep));\r
+               cmd.add(createCopyFragment("ptMergeCopyStep", step1.replace("\n", " && ")));\r
+               return cmd;\r
+       }\r
+\r
+       /**\r
+        * Returns the primary repository url\r
+        *\r
+        * @param user\r
+        * @param repository\r
+        * @return the primary repository url\r
+        */\r
+       protected String getRepositoryUrl(UserModel user, RepositoryModel repository) {\r
+               HttpServletRequest req = ((WebRequest) getRequest()).getHttpServletRequest();\r
+               String primaryurl = app().gitblit().getRepositoryUrls(req, user, repository).get(0).url;\r
+               String url = primaryurl;\r
+               try {\r
+                       url = new URIish(primaryurl).setUser(null).toString();\r
+               } catch (Exception e) {\r
+               }\r
+               return url;\r
+       }\r
+\r
+       /**\r
+        * Returns the ticket (if any) that this commit references.\r
+        *\r
+        * @param commit\r
+        * @return null or a ticket\r
+        */\r
+       protected TicketModel getTicket(RevCommit commit) {\r
+               try {\r
+                       Map<String, Ref> refs = getRepository().getRefDatabase().getRefs(Constants.R_TICKETS_PATCHSETS);\r
+                       for (Map.Entry<String, Ref> entry : refs.entrySet()) {\r
+                               if (entry.getValue().getObjectId().equals(commit.getId())) {\r
+                                       long id = PatchsetCommand.getTicketNumber(entry.getKey());\r
+                                       TicketModel ticket = app().tickets().getTicket(getRepositoryModel(), id);\r
+                                       return ticket;\r
+                               }\r
+                       }\r
+               } catch (Exception e) {\r
+                       logger().error("failed to determine ticket from ref", e);\r
+               }\r
+               return null;\r
+       }\r
+\r
+       protected String getPatchsetTypeCss(PatchsetType type) {\r
+               String typeCss;\r
+               switch (type) {\r
+                       case Rebase:\r
+                       case Rebase_Squash:\r
+                               typeCss = getLozengeClass(Status.Declined, false);\r
+                               break;\r
+                       case Squash:\r
+                       case Amend:\r
+                               typeCss = getLozengeClass(Status.On_Hold, false);\r
+                               break;\r
+                       case Proposal:\r
+                               typeCss = getLozengeClass(Status.New, false);\r
+                               break;\r
+                       case FastForward:\r
+                       default:\r
+                               typeCss = null;\r
+                       break;\r
+               }\r
+               return typeCss;\r
+       }\r
+\r
+       @Override\r
+       protected String getPageName() {\r
+               return getString("gb.ticket");\r
+       }\r
+\r
+       @Override\r
+       protected Class<? extends BasePage> getRepoNavPageClass() {\r
+               return TicketsPage.class;\r
+       }\r
+\r
+       @Override\r
+       protected String getPageTitle(String repositoryName) {\r
+               return "#" + ticket.number + " - " + ticket.title;\r
+       }\r
+\r
+       protected Fragment createCopyFragment(String wicketId, String text) {\r
+               if (app().settings().getBoolean(Keys.web.allowFlashCopyToClipboard, true)) {\r
+                       // clippy: flash-based copy & paste\r
+                       Fragment copyFragment = new Fragment(wicketId, "clippyPanel", this);\r
+                       String baseUrl = WicketUtils.getGitblitURL(getRequest());\r
+                       ShockWaveComponent clippy = new ShockWaveComponent("clippy", baseUrl + "/clippy.swf");\r
+                       clippy.setValue("flashVars", "text=" + StringUtils.encodeURL(text));\r
+                       copyFragment.add(clippy);\r
+                       return copyFragment;\r
+               } else {\r
+                       // javascript: manual copy & paste with modal browser prompt dialog\r
+                       Fragment copyFragment = new Fragment(wicketId, "jsPanel", this);\r
+                       ContextImage img = WicketUtils.newImage("copyIcon", "clippy.png");\r
+                       img.add(new JavascriptTextPrompt("onclick", "Copy to Clipboard (Ctrl+C, Enter)", text));\r
+                       copyFragment.add(img);\r
+                       return copyFragment;\r
+               }\r
+       }\r
+}\r
diff --git a/src/main/java/com/gitblit/wicket/pages/TicketsPage.html b/src/main/java/com/gitblit/wicket/pages/TicketsPage.html
new file mode 100644 (file)
index 0000000..9054490
--- /dev/null
@@ -0,0 +1,215 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">\r
+<html xmlns="http://www.w3.org/1999/xhtml"  \r
+      xmlns:wicket="http://wicket.apache.org/dtds.data/wicket-xhtml1.3-strict.dtd"  \r
+      xml:lang="en"  \r
+      lang="en"> \r
+\r
+<body>\r
+<wicket:extend>\r
+\r
+       <!-- search tickets form -->\r
+       <div class="hidden-phone pull-right">\r
+               <form class="form-search" style="margin: 0px;" wicket:id="ticketSearchForm">\r
+                       <div class="input-append">\r
+                               <input type="text" class="search-query" style="width: 170px;border-radius: 14px 0 0 14px; padding-left: 14px;" id="ticketSearchBox" wicket:id="ticketSearchBox" value=""/>\r
+                               <button class="btn" style="border-radius: 0 14px 14px 0px;margin-left:-5px;" type="submit"><i class="icon-search"></i></button>\r
+                       </div>\r
+               </form>\r
+       </div>\r
+\r
+       <ul class="nav nav-tabs">\r
+               <li class="active"><a data-toggle="tab" href="#tickets"><i style="color:#888;"class="fa fa-ticket"></i> <wicket:message key="gb.tickets"></wicket:message></a></li>\r
+               <li><a data-toggle="tab" href="#milestones"><i style="color:#888;"class="fa fa-bullseye"></i> <wicket:message key="gb.milestones"></wicket:message></a></li>\r
+       </ul>\r
+       <div class="tab-content">\r
+       <div class="tab-pane active" id="tickets">\r
+       <div class="row" style="min-height:400px;" >\r
+       \r
+               <!-- query controls -->\r
+               <div class="span3">\r
+                       <div wicket:id="milestonePanel"></div>\r
+                       <div class="hidden-phone">\r
+                               <ul class="nav nav-list">\r
+                                       <li class="nav-header"><wicket:message key="gb.queries"></wicket:message></li>\r
+                                       <li><a wicket:id="changesQuery"><i class="fa fa-code-fork"></i> <wicket:message key="gb.proposalTickets"></wicket:message></a></li>\r
+                                       <li><a wicket:id="bugsQuery"><i class="fa fa-bug"></i> <wicket:message key="gb.bugTickets"></wicket:message></a></li>\r
+                                       <li><a wicket:id="enhancementsQuery"><i class="fa fa-magic"></i> <wicket:message key="gb.enhancementTickets"></wicket:message></a></li>\r
+                                       <li><a wicket:id="tasksQuery"><i class="fa fa-ticket"></i> <wicket:message key="gb.taskTickets"></wicket:message></a></li>\r
+                                       <li><a wicket:id="questionsQuery"><i class="fa fa-question"></i> <wicket:message key="gb.questionTickets"></wicket:message></a></li>\r
+                                       <li wicket:id="userDivider" class="divider"></li>\r
+                                       <li><a wicket:id="createdQuery"><i class="fa fa-user"></i> <wicket:message key="gb.yourCreatedTickets"></wicket:message></a></li>\r
+                                       <li><a wicket:id="watchedQuery"><i class="fa fa-eye"></i> <wicket:message key="gb.yourWatchedTickets"></wicket:message></a></li>\r
+                                       <li><a wicket:id="mentionsQuery"><i class="fa fa-comment"></i> <wicket:message key="gb.mentionsMeTickets"></wicket:message></a></li>\r
+                                       <li class="divider"></li>\r
+                                       <li><a wicket:id="resetQuery"><i class="fa fa-bolt"></i> <wicket:message key="gb.reset"></wicket:message></a></li>\r
+                               </ul>\r
+                       </div>                  \r
+                       <div wicket:id="dynamicQueries" class="hidden-phone"></div>\r
+               </div>\r
+       \r
+               <!-- tickets -->\r
+               <div class="span9">\r
+                       <div class="btn-toolbar" style="margin-top: 0px;">\r
+                               <div class="btn-group">\r
+                                       <a class="btn dropdown-toggle" data-toggle="dropdown" href="#"><wicket:message key="gb.status"></wicket:message>: <span style="font-weight:bold;" wicket:id="selectedStatii"></span> <span class="caret"></span></a>\r
+                                       <ul class="dropdown-menu">\r
+                                       <li><a wicket:id="openTickets">open</a></li>\r
+                                       <li><a wicket:id="closedTickets">closed</a></li>\r
+                                       <li><a wicket:id="allTickets">all</a></li>\r
+                                       <li class="divider"></li>\r
+                                       <li wicket:id="statii"><span wicket:id="statusLink"></span></li>\r
+                                       </ul>\r
+                               </div>\r
+\r
+                               <div class="btn-group hidden-phone">                            \r
+                                       <a class="btn dropdown-toggle" data-toggle="dropdown" href="#"><i class="fa fa-user"></i> <wicket:message key="gb.responsible"></wicket:message>: <span style="font-weight:bold;" wicket:id="currentResponsible"></span> <span class="caret"></span></a>\r
+                                       <ul class="dropdown-menu">\r
+                                       <li wicket:id="responsible"><span wicket:id="responsibleLink"></span></li>\r
+                                       <li class="divider"></li>\r
+                                       <li><a wicket:id="resetResponsible"><i class="fa fa-bolt"></i> <wicket:message key="gb.reset"></wicket:message></a></li>\r
+                                       </ul>\r
+                               </div>\r
+\r
+                               <div class="btn-group">\r
+                                       <a class="btn dropdown-toggle" data-toggle="dropdown" href="#"><i class="fa fa-sort"></i> <wicket:message key="gb.sort"></wicket:message>: <span style="font-weight:bold;" wicket:id="currentSort"></span> <span class="caret"></span></a>\r
+                                       <ul class="dropdown-menu">\r
+                                       <li wicket:id="sort"><span wicket:id="sortLink"></span></li>\r
+                                       </ul>\r
+                               </div>\r
+                       \r
+                               <div class="btn-group pull-right">\r
+                                       <div class="pagination pagination-right pagination-small">\r
+                                               <ul>\r
+                                                       <li><a wicket:id="prevLink"><i class="fa fa-angle-double-left"></i></a></li>\r
+                                               <li wicket:id="pageLink"><span wicket:id="page"></span></li>\r
+                                               <li><a wicket:id="nextLink"><i class="fa fa-angle-double-right"></i></a></li>\r
+                                               </ul>\r
+                                       </div>\r
+                               </div>\r
+                       </div>\r
+               \r
+               \r
+                       <table class="table tickets">                   \r
+                       <tbody>\r
+                       <tr wicket:id="ticket">\r
+                               <td class="ticket-list-icon">\r
+                                       <i wicket:id="state"></i>\r
+                               </td>\r
+                       <td>\r
+                               <span wicket:id="title">[title]</span> <span wicket:id="labels" style="font-weight: normal;color:white;"><span class="label" wicket:id="label"></span></span>\r
+                               <div class="ticket-list-details">\r
+                                       <span style="padding-right: 10px;" class="hidden-phone">\r
+                                               <wicket:message key="gb.createdBy"></wicket:message>\r
+                                               <span style="padding: 0px 2px" wicket:id="createdBy">[createdBy]</span> <span class="date" wicket:id="createDate">[create date]</span>\r
+                                       </span>\r
+                                       <span wicket:id="indicators" style="white-space:nowrap;"><i wicket:id="icon"></i> <span style="padding-right:10px;" wicket:id="count"></span></span>\r
+                               </div>\r
+                               <div class="hidden-phone" wicket:id="updated"></div>\r
+                       </td>\r
+                       <td class="ticket-list-state">\r
+                                       <span class="badge badge-info" wicket:id="votes"></span>\r
+                       </td>\r
+                       <td class="hidden-phone ticket-list-state">\r
+                                       <i wicket:message="title:gb.watching" style="color:#888;" class="fa fa-eye" wicket:id="watching"></i>\r
+                       </td>\r
+                       <td class="ticket-list-state">\r
+                                       <div wicket:id="status"></div>\r
+                       </td>\r
+                       <td class="indicators">\r
+                               <div>                                                           \r
+                                       <b>#<span wicket:id="id">[id]</span></b>\r
+                               </div>\r
+                               <div wicket:id="responsible"></div>\r
+                       </td>\r
+                       </tr>\r
+               </tbody>\r
+                       </table>\r
+               \r
+                       <div class="btn-group pull-right">\r
+                                       <div class="pagination pagination-right pagination-small">\r
+                                               <ul>\r
+                                                       <li><a wicket:id="prevLink"><i class="fa fa-angle-double-left"></i></a></li>\r
+                                               <li wicket:id="pageLink"><span wicket:id="page"></span></li>\r
+                                               <li><a wicket:id="nextLink"><i class="fa fa-angle-double-right"></i></a></li>\r
+                                               </ul>\r
+                                       </div>\r
+                               </div>\r
+                       </div>\r
+               </div>\r
+       </div>\r
+       <div class="tab-pane" id="milestones">\r
+               <div class="row">\r
+                       <div class="span9" wicket:id="milestoneList">\r
+                               <h3><span wicket:id="milestoneName"></span> <small><span wicket:id="milestoneState"></span></small></h3>\r
+                               <span wicket:id="milestoneDue"></span>\r
+                       </div>\r
+               </div>\r
+       </div>\r
+</div>\r
+\r
+<wicket:fragment wicket:id="noMilestoneFragment">\r
+<table style="width: 100%;padding-bottom: 5px;">\r
+<tbody>\r
+<tr>\r
+       <td style="color:#888;"><wicket:message key="gb.noMilestoneSelected"></wicket:message></td>\r
+       <td><div wicket:id="milestoneDropdown"></div></td>\r
+</tr>\r
+</tbody>\r
+</table>\r
+</wicket:fragment>\r
+\r
+<wicket:fragment wicket:id="milestoneProgressFragment">\r
+<table style="width: 100%;padding-bottom: 5px;">\r
+<tbody>\r
+<tr>\r
+       <td style="color:#888;">\r
+               <div><i style="color:#888;"class="fa fa-bullseye"></i> <span style="font-weight:bold;" wicket:id="currentMilestone"></span></div>\r
+               <div><i style="color:#888;"class="fa fa-calendar"></i> <span style="font-weight:bold;" wicket:id="currentDueDate"></span></div>\r
+       </td>\r
+       <td>\r
+               <div wicket:id="milestoneDropdown"></div>\r
+       </td>\r
+</tr>\r
+</tbody>\r
+</table>\r
+<div style="clear:both;padding-bottom: 10px;">\r
+       <div style="margin-bottom: 5px;" class="progress progress-success">\r
+               <div class="bar" wicket:id="progress"></div>\r
+       </div>\r
+       <div class="milestoneOverview">\r
+               <span wicket:id="openTickets" />,\r
+               <span wicket:id="closedTickets" />,\r
+               <span wicket:id="totalTickets" />\r
+       </div>\r
+</div>\r
+</wicket:fragment>\r
+\r
+<wicket:fragment wicket:id="milestoneDropdownFragment">\r
+<div class="btn-group pull-right">\r
+       <a class="btn dropdown-toggle" data-toggle="dropdown" href="#"><i class="fa fa-gear"></i> <span class="caret"></span></a>\r
+       <ul class="dropdown-menu">\r
+               <li wicket:id="milestone"><span wicket:id="milestoneLink">[milestone]</span></li>\r
+               <li class="divider"></li>\r
+               <li><a wicket:id="resetMilestone"><i class="fa fa-bolt"></i> <wicket:message key="gb.reset"></wicket:message></a></li>\r
+       </ul>\r
+</div>\r
+</wicket:fragment>\r
+\r
+<wicket:fragment wicket:id="dynamicQueriesFragment">\r
+       <hr/>\r
+       <ul class="nav nav-list">\r
+               <li class="nav-header"><wicket:message key="gb.topicsAndLabels"></wicket:message></li>\r
+               <li class="dynamicQuery" wicket:id="dynamicQuery"><span><span wicket:id="swatch"></span> <span wicket:id="link"></span></span><span class="pull-right"><i style="font-size: 18px;" wicket:id="checked"></i></span></li>\r
+       </ul>\r
+</wicket:fragment>\r
+\r
+<wicket:fragment wicket:id="updatedFragment">\r
+       <div class="ticket-list-details">\r
+               <wicket:message key="gb.updatedBy"></wicket:message>\r
+               <span style="padding: 0px 2px" wicket:id="updatedBy">[updatedBy]</span> <span class="date" wicket:id="updateDate">[update date]</span>\r
+       </div>\r
+</wicket:fragment>\r
+\r
+</wicket:extend>\r
+</body>\r
+</html>
\ No newline at end of file
diff --git a/src/main/java/com/gitblit/wicket/pages/TicketsPage.java b/src/main/java/com/gitblit/wicket/pages/TicketsPage.java
new file mode 100644 (file)
index 0000000..525658c
--- /dev/null
@@ -0,0 +1,878 @@
+/*\r
+ * Copyright 2013 gitblit.com.\r
+ *\r
+ * Licensed under the Apache License, Version 2.0 (the "License");\r
+ * you may not use this file except in compliance with the License.\r
+ * You may obtain a copy of the License at\r
+ *\r
+ *     http://www.apache.org/licenses/LICENSE-2.0\r
+ *\r
+ * Unless required by applicable law or agreed to in writing, software\r
+ * distributed under the License is distributed on an "AS IS" BASIS,\r
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r
+ * See the License for the specific language governing permissions and\r
+ * limitations under the License.\r
+ */\r
+package com.gitblit.wicket.pages;\r
+\r
+import java.io.Serializable;\r
+import java.text.MessageFormat;\r
+import java.util.ArrayList;\r
+import java.util.Arrays;\r
+import java.util.Collections;\r
+import java.util.List;\r
+import java.util.Set;\r
+import java.util.TreeSet;\r
+\r
+import org.apache.wicket.Component;\r
+import org.apache.wicket.PageParameters;\r
+import org.apache.wicket.behavior.SimpleAttributeModifier;\r
+import org.apache.wicket.markup.html.basic.Label;\r
+import org.apache.wicket.markup.html.form.TextField;\r
+import org.apache.wicket.markup.html.link.BookmarkablePageLink;\r
+import org.apache.wicket.markup.html.panel.Fragment;\r
+import org.apache.wicket.markup.repeater.Item;\r
+import org.apache.wicket.markup.repeater.data.DataView;\r
+import org.apache.wicket.markup.repeater.data.ListDataProvider;\r
+import org.apache.wicket.model.IModel;\r
+import org.apache.wicket.model.Model;\r
+import org.apache.wicket.request.target.basic.RedirectRequestTarget;\r
+\r
+import com.gitblit.Constants;\r
+import com.gitblit.Constants.AccessPermission;\r
+import com.gitblit.Keys;\r
+import com.gitblit.models.RegistrantAccessPermission;\r
+import com.gitblit.models.TicketModel;\r
+import com.gitblit.models.TicketModel.Status;\r
+import com.gitblit.models.UserModel;\r
+import com.gitblit.tickets.QueryBuilder;\r
+import com.gitblit.tickets.QueryResult;\r
+import com.gitblit.tickets.TicketIndexer.Lucene;\r
+import com.gitblit.tickets.TicketLabel;\r
+import com.gitblit.tickets.TicketMilestone;\r
+import com.gitblit.tickets.TicketResponsible;\r
+import com.gitblit.utils.ArrayUtils;\r
+import com.gitblit.utils.StringUtils;\r
+import com.gitblit.wicket.GitBlitWebSession;\r
+import com.gitblit.wicket.SessionlessForm;\r
+import com.gitblit.wicket.WicketUtils;\r
+import com.gitblit.wicket.panels.GravatarImage;\r
+import com.gitblit.wicket.panels.LinkPanel;\r
+\r
+public class TicketsPage extends TicketBasePage {\r
+\r
+       final TicketResponsible any;\r
+\r
+       public static final String [] openStatii = new String [] { Status.New.name().toLowerCase(), Status.Open.name().toLowerCase() };\r
+\r
+       public static final String [] closedStatii = new String [] { "!" + Status.New.name().toLowerCase(), "!" + Status.Open.name().toLowerCase() };\r
+\r
+       public TicketsPage(PageParameters params) {\r
+               super(params);\r
+\r
+               if (!app().tickets().isReady()) {\r
+                       // tickets prohibited\r
+                       setResponsePage(SummaryPage.class, WicketUtils.newRepositoryParameter(repositoryName));\r
+               } else if (!app().tickets().hasTickets(getRepositoryModel())) {\r
+                       // no tickets for this repository\r
+                       setResponsePage(NoTicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));\r
+               } else {\r
+                       String id = WicketUtils.getObject(params);\r
+                       if (id != null) {\r
+                               // view the ticket with the TicketPage\r
+                               setResponsePage(TicketPage.class, params);\r
+                       }\r
+               }\r
+\r
+               // set stateless page preference\r
+               setStatelessHint(true);\r
+\r
+               any = new TicketResponsible("any", "[* TO *]", null);\r
+\r
+               UserModel user = GitBlitWebSession.get().getUser();\r
+               boolean isAuthenticated = user != null && user.isAuthenticated;\r
+\r
+               final String [] statiiParam = params.getStringArray(Lucene.status.name());\r
+               final String assignedToParam = params.getString(Lucene.responsible.name(), null);\r
+               final String milestoneParam = params.getString(Lucene.milestone.name(), null);\r
+               final String queryParam = params.getString("q", null);\r
+               final String searchParam = params.getString("s", null);\r
+               final String sortBy = Lucene.fromString(params.getString("sort", Lucene.created.name())).name();\r
+               final boolean desc = !"asc".equals(params.getString("direction", "desc"));\r
+\r
+\r
+               // add search form\r
+               TicketSearchForm searchForm = new TicketSearchForm("ticketSearchForm", repositoryName, searchParam);\r
+               add(searchForm);\r
+               searchForm.setTranslatedAttributes();\r
+\r
+               final String activeQuery;\r
+               if (!StringUtils.isEmpty(searchParam)) {\r
+                       activeQuery = searchParam;\r
+               } else if (StringUtils.isEmpty(queryParam)) {\r
+                       activeQuery = "";\r
+               } else {\r
+                       activeQuery = queryParam;\r
+               }\r
+\r
+               // build Lucene query from defaults and request parameters\r
+               QueryBuilder qb = new QueryBuilder(queryParam);\r
+               if (!qb.containsField(Lucene.rid.name())) {\r
+                       // specify the repository\r
+                       qb.and(Lucene.rid.matches(getRepositoryModel().getRID()));\r
+               }\r
+               if (!qb.containsField(Lucene.responsible.name())) {\r
+                       // specify the responsible\r
+                       qb.and(Lucene.responsible.matches(assignedToParam));\r
+               }\r
+               if (!qb.containsField(Lucene.milestone.name())) {\r
+                       // specify the milestone\r
+                       qb.and(Lucene.milestone.matches(milestoneParam));\r
+               }\r
+               if (!qb.containsField(Lucene.status.name()) && !ArrayUtils.isEmpty(statiiParam)) {\r
+                       // specify the states\r
+                       boolean not = false;\r
+                       QueryBuilder q = new QueryBuilder();\r
+                       for (String state : statiiParam) {\r
+                               if (state.charAt(0) == '!') {\r
+                                       not = true;\r
+                                       q.and(Lucene.status.doesNotMatch(state.substring(1)));\r
+                               } else {\r
+                                       q.or(Lucene.status.matches(state));\r
+                               }\r
+                       }\r
+                       if (not) {\r
+                               qb.and(q.toString());\r
+                       } else {\r
+                               qb.and(q.toSubquery().toString());\r
+                       }\r
+               }\r
+               final String luceneQuery = qb.build();\r
+\r
+               // open milestones\r
+               List<TicketMilestone> milestones = app().tickets().getMilestones(getRepositoryModel(), Status.Open);\r
+               TicketMilestone currentMilestone = null;\r
+               if (!StringUtils.isEmpty(milestoneParam)) {\r
+                       for (TicketMilestone tm : milestones) {\r
+                               if (tm.name.equals(milestoneParam)) {\r
+                                       // get the milestone (queries the index)\r
+                                       currentMilestone = app().tickets().getMilestone(getRepositoryModel(), milestoneParam);\r
+                                       break;\r
+                               }\r
+                       }\r
+\r
+                       if (currentMilestone == null) {\r
+                               // milestone not found, create a temporary one\r
+                               currentMilestone = new TicketMilestone(milestoneParam);\r
+                       }\r
+               }\r
+\r
+               Fragment milestonePanel;\r
+               if (currentMilestone == null) {\r
+                       milestonePanel = new Fragment("milestonePanel", "noMilestoneFragment", this);\r
+                       add(milestonePanel);\r
+               } else {\r
+                       milestonePanel = new Fragment("milestonePanel", "milestoneProgressFragment", this);\r
+                       milestonePanel.add(new Label("currentMilestone", currentMilestone.name));\r
+                       if (currentMilestone.due == null) {\r
+                               milestonePanel.add(new Label("currentDueDate", getString("gb.notSpecified")));\r
+                       } else {\r
+                               milestonePanel.add(WicketUtils.createDateLabel("currentDueDate", currentMilestone.due, GitBlitWebSession\r
+                                               .get().getTimezone(), getTimeUtils(), false));\r
+                       }\r
+                       Label label = new Label("progress");\r
+                       WicketUtils.setCssStyle(label, "width:" + currentMilestone.getProgress() + "%;");\r
+                       milestonePanel.add(label);\r
+\r
+                       milestonePanel.add(new LinkPanel("openTickets", null,\r
+                                       currentMilestone.getOpenTickets() + " open",\r
+                                       TicketsPage.class,\r
+                                       queryParameters(null, currentMilestone.name, openStatii, null, sortBy, desc, 1)));\r
+\r
+                       milestonePanel.add(new LinkPanel("closedTickets", null,\r
+                                       currentMilestone.getClosedTickets() + " closed",\r
+                                       TicketsPage.class,\r
+                                       queryParameters(null, currentMilestone.name, closedStatii, null, sortBy, desc, 1)));\r
+\r
+                       milestonePanel.add(new Label("totalTickets", currentMilestone.getTotalTickets() + " total"));\r
+                       add(milestonePanel);\r
+               }\r
+\r
+               Fragment milestoneDropdown = new Fragment("milestoneDropdown", "milestoneDropdownFragment", this);\r
+               PageParameters resetMilestone = queryParameters(queryParam, null, statiiParam, assignedToParam, sortBy, desc, 1);\r
+               milestoneDropdown.add(new BookmarkablePageLink<Void>("resetMilestone", TicketsPage.class, resetMilestone));\r
+\r
+               ListDataProvider<TicketMilestone> milestonesDp = new ListDataProvider<TicketMilestone>(milestones);\r
+               DataView<TicketMilestone> milestonesMenu = new DataView<TicketMilestone>("milestone", milestonesDp) {\r
+                       private static final long serialVersionUID = 1L;\r
+\r
+                       @Override\r
+                       public void populateItem(final Item<TicketMilestone> item) {\r
+                               final TicketMilestone tm = item.getModelObject();\r
+                               PageParameters params = queryParameters(queryParam, tm.name, statiiParam, assignedToParam, sortBy, desc, 1);\r
+                               item.add(new LinkPanel("milestoneLink", null, tm.name, TicketsPage.class, params).setRenderBodyOnly(true));\r
+                       }\r
+               };\r
+               milestoneDropdown.add(milestonesMenu);\r
+               milestonePanel.add(milestoneDropdown);\r
+\r
+               // search or query tickets\r
+               int page = Math.max(1,  WicketUtils.getPage(params));\r
+               int pageSize = app().settings().getInteger(Keys.tickets.perPage, 25);\r
+               List<QueryResult> results;\r
+               if (StringUtils.isEmpty(searchParam)) {\r
+                       results = app().tickets().queryFor(luceneQuery, page, pageSize, sortBy, desc);\r
+               } else {\r
+                       results = app().tickets().searchFor(getRepositoryModel(), searchParam, page, pageSize);\r
+               }\r
+               int totalResults = results.size() == 0 ? 0 : results.get(0).totalResults;\r
+\r
+               // standard queries\r
+               add(new BookmarkablePageLink<Void>("changesQuery", TicketsPage.class,\r
+                               queryParameters(\r
+                                               Lucene.type.matches(TicketModel.Type.Proposal.name()),\r
+                                               milestoneParam,\r
+                                               statiiParam,\r
+                                               assignedToParam,\r
+                                               sortBy,\r
+                                               desc,\r
+                                               1)));\r
+\r
+               add(new BookmarkablePageLink<Void>("bugsQuery", TicketsPage.class,\r
+                               queryParameters(\r
+                                               Lucene.type.matches(TicketModel.Type.Bug.name()),\r
+                                               milestoneParam,\r
+                                               statiiParam,\r
+                                               assignedToParam,\r
+                                               sortBy,\r
+                                               desc,\r
+                                               1)));\r
+\r
+               add(new BookmarkablePageLink<Void>("enhancementsQuery", TicketsPage.class,\r
+                               queryParameters(\r
+                                               Lucene.type.matches(TicketModel.Type.Enhancement.name()),\r
+                                               milestoneParam,\r
+                                               statiiParam,\r
+                                               assignedToParam,\r
+                                               sortBy,\r
+                                               desc,\r
+                                               1)));\r
+\r
+               add(new BookmarkablePageLink<Void>("tasksQuery", TicketsPage.class,\r
+                               queryParameters(\r
+                                               Lucene.type.matches(TicketModel.Type.Task.name()),\r
+                                               milestoneParam,\r
+                                               statiiParam,\r
+                                               assignedToParam,\r
+                                               sortBy,\r
+                                               desc,\r
+                                               1)));\r
+\r
+               add(new BookmarkablePageLink<Void>("questionsQuery", TicketsPage.class,\r
+                               queryParameters(\r
+                                               Lucene.type.matches(TicketModel.Type.Question.name()),\r
+                                               milestoneParam,\r
+                                               statiiParam,\r
+                                               assignedToParam,\r
+                                               sortBy,\r
+                                               desc,\r
+                                               1)));\r
+\r
+               add(new BookmarkablePageLink<Void>("resetQuery", TicketsPage.class,\r
+                               queryParameters(\r
+                                               null,\r
+                                               milestoneParam,\r
+                                               openStatii,\r
+                                               null,\r
+                                               null,\r
+                                               true,\r
+                                               1)));\r
+\r
+               if (isAuthenticated) {\r
+                       add(new Label("userDivider"));\r
+                       add(new BookmarkablePageLink<Void>("createdQuery", TicketsPage.class,\r
+                                       queryParameters(\r
+                                                       Lucene.createdby.matches(user.username),\r
+                                                       milestoneParam,\r
+                                                       statiiParam,\r
+                                                       assignedToParam,\r
+                                                       sortBy,\r
+                                                       desc,\r
+                                                       1)));\r
+\r
+                       add(new BookmarkablePageLink<Void>("watchedQuery", TicketsPage.class,\r
+                                       queryParameters(\r
+                                                       Lucene.watchedby.matches(user.username),\r
+                                                       milestoneParam,\r
+                                                       statiiParam,\r
+                                                       assignedToParam,\r
+                                                       sortBy,\r
+                                                       desc,\r
+                                                       1)));\r
+                       add(new BookmarkablePageLink<Void>("mentionsQuery", TicketsPage.class,\r
+                                       queryParameters(\r
+                                                       Lucene.mentions.matches(user.username),\r
+                                                       milestoneParam,\r
+                                                       statiiParam,\r
+                                                       assignedToParam,\r
+                                                       sortBy,\r
+                                                       desc,\r
+                                                       1)));\r
+               } else {\r
+                       add(new Label("userDivider").setVisible(false));\r
+                       add(new Label("createdQuery").setVisible(false));\r
+                       add(new Label("watchedQuery").setVisible(false));\r
+                       add(new Label("mentionsQuery").setVisible(false));\r
+               }\r
+\r
+               Set<TicketQuery> dynamicQueries = new TreeSet<TicketQuery>();\r
+               for (TicketLabel label : app().tickets().getLabels(getRepositoryModel())) {\r
+                       String q = QueryBuilder.q(Lucene.labels.matches(label.name)).build();\r
+                       dynamicQueries.add(new TicketQuery(label.name, q).color(label.color));\r
+               }\r
+\r
+               for (QueryResult ticket : results) {\r
+                       if (!StringUtils.isEmpty(ticket.topic)) {\r
+                               String q = QueryBuilder.q(Lucene.topic.matches(ticket.topic)).build();\r
+                               dynamicQueries.add(new TicketQuery(ticket.topic, q));\r
+                       }\r
+\r
+                       if (!ArrayUtils.isEmpty(ticket.labels)) {\r
+                               for (String label : ticket.labels) {\r
+                                       String q = QueryBuilder.q(Lucene.labels.matches(label)).build();\r
+                                       dynamicQueries.add(new TicketQuery(label, q));\r
+                               }\r
+                       }\r
+               }\r
+\r
+               if (dynamicQueries.size() == 0) {\r
+                       add(new Label("dynamicQueries").setVisible(false));\r
+               } else {\r
+                       Fragment fragment = new Fragment("dynamicQueries", "dynamicQueriesFragment", this);\r
+                       ListDataProvider<TicketQuery> dynamicQueriesDp = new ListDataProvider<TicketQuery>(new ArrayList<TicketQuery>(dynamicQueries));\r
+                       DataView<TicketQuery> dynamicQueriesList = new DataView<TicketQuery>("dynamicQuery", dynamicQueriesDp) {\r
+                               private static final long serialVersionUID = 1L;\r
+\r
+                               @Override\r
+                               public void populateItem(final Item<TicketQuery> item) {\r
+                                       final TicketQuery tq = item.getModelObject();\r
+                                       Component swatch = new Label("swatch", "&nbsp;").setEscapeModelStrings(false);\r
+                                       if (StringUtils.isEmpty(tq.color)) {\r
+                                               // calculate a color\r
+                                               tq.color = StringUtils.getColor(tq.name);\r
+                                       }\r
+                                       String background = MessageFormat.format("background-color:{0};", tq.color);\r
+                                       swatch.add(new SimpleAttributeModifier("style", background));\r
+                                       item.add(swatch);\r
+                                       if (activeQuery.contains(tq.query)) {\r
+                                               // selected\r
+                                               String q = QueryBuilder.q(activeQuery).remove(tq.query).build();\r
+                                               PageParameters params = queryParameters(q, milestoneParam, statiiParam, assignedToParam, sortBy, desc, 1);\r
+                                               item.add(new LinkPanel("link", "active", tq.name, TicketsPage.class, params).setRenderBodyOnly(true));\r
+                                               Label checked = new Label("checked");\r
+                                               WicketUtils.setCssClass(checked, "iconic-o-x");\r
+                                               item.add(checked);\r
+                                               item.add(new SimpleAttributeModifier("style", background));\r
+                                       } else {\r
+                                               // unselected\r
+                                               String q = QueryBuilder.q(queryParam).toSubquery().and(tq.query).build();\r
+                                               PageParameters params = queryParameters(q, milestoneParam, statiiParam, assignedToParam, sortBy, desc, 1);\r
+                                               item.add(new LinkPanel("link", null, tq.name, TicketsPage.class, params).setRenderBodyOnly(true));\r
+                                               item.add(new Label("checked").setVisible(false));\r
+                                       }\r
+                               }\r
+                       };\r
+                       fragment.add(dynamicQueriesList);\r
+                       add(fragment);\r
+               }\r
+\r
+               // states\r
+               if (ArrayUtils.isEmpty(statiiParam)) {\r
+                       add(new Label("selectedStatii", getString("gb.all")));\r
+               } else {\r
+                       add(new Label("selectedStatii", StringUtils.flattenStrings(Arrays.asList(statiiParam), ",")));\r
+               }\r
+               add(new BookmarkablePageLink<Void>("openTickets", TicketsPage.class, queryParameters(queryParam, milestoneParam, openStatii, assignedToParam, sortBy, desc, 1)));\r
+               add(new BookmarkablePageLink<Void>("closedTickets", TicketsPage.class, queryParameters(queryParam, milestoneParam, closedStatii, assignedToParam, sortBy, desc, 1)));\r
+               add(new BookmarkablePageLink<Void>("allTickets", TicketsPage.class, queryParameters(queryParam, milestoneParam, null, assignedToParam, sortBy, desc, 1)));\r
+\r
+               // by status\r
+               List<Status> statii = Arrays.asList(Status.values());\r
+               ListDataProvider<Status> resolutionsDp = new ListDataProvider<Status>(statii);\r
+               DataView<Status> statiiLinks = new DataView<Status>("statii", resolutionsDp) {\r
+                       private static final long serialVersionUID = 1L;\r
+\r
+                       @Override\r
+                       public void populateItem(final Item<Status> item) {\r
+                               final Status status = item.getModelObject();\r
+                               PageParameters p = queryParameters(queryParam, milestoneParam, new String [] { status.name().toLowerCase() }, assignedToParam, sortBy, desc, 1);\r
+                               String css = getStatusClass(status);\r
+                               item.add(new LinkPanel("statusLink", css, status.toString(), TicketsPage.class, p).setRenderBodyOnly(true));\r
+                       }\r
+               };\r
+               add(statiiLinks);\r
+\r
+               // responsible filter\r
+               List<TicketResponsible> responsibles = new ArrayList<TicketResponsible>();\r
+               for (RegistrantAccessPermission perm : app().repositories().getUserAccessPermissions(getRepositoryModel())) {\r
+                       if (perm.permission.atLeast(AccessPermission.PUSH)) {\r
+                               UserModel u = app().users().getUserModel(perm.registrant);\r
+                               responsibles.add(new TicketResponsible(u));\r
+                       }\r
+               }\r
+               Collections.sort(responsibles);\r
+               responsibles.add(0, any);\r
+\r
+               TicketResponsible currentResponsible = null;\r
+               for (TicketResponsible u : responsibles) {\r
+                       if (u.username.equals(assignedToParam)) {\r
+                               currentResponsible = u;\r
+                               break;\r
+                       }\r
+               }\r
+\r
+               add(new Label("currentResponsible", currentResponsible == null ? "" : currentResponsible.displayname));\r
+               ListDataProvider<TicketResponsible> responsibleDp = new ListDataProvider<TicketResponsible>(responsibles);\r
+               DataView<TicketResponsible> responsibleMenu = new DataView<TicketResponsible>("responsible", responsibleDp) {\r
+                       private static final long serialVersionUID = 1L;\r
+\r
+                       @Override\r
+                       public void populateItem(final Item<TicketResponsible> item) {\r
+                               final TicketResponsible u = item.getModelObject();\r
+                               PageParameters params = queryParameters(queryParam, milestoneParam, statiiParam, u.username, sortBy, desc, 1);\r
+                               item.add(new LinkPanel("responsibleLink", null, u.displayname, TicketsPage.class, params).setRenderBodyOnly(true));\r
+                       }\r
+               };\r
+               add(responsibleMenu);\r
+               PageParameters resetResponsibleParams = queryParameters(queryParam, milestoneParam, statiiParam, null, sortBy, desc, 1);\r
+               add(new BookmarkablePageLink<Void>("resetResponsible", TicketsPage.class, resetResponsibleParams));\r
+\r
+               List<TicketSort> sortChoices = new ArrayList<TicketSort>();\r
+               sortChoices.add(new TicketSort(getString("gb.sortNewest"), Lucene.created.name(), true));\r
+               sortChoices.add(new TicketSort(getString("gb.sortOldest"), Lucene.created.name(), false));\r
+               sortChoices.add(new TicketSort(getString("gb.sortMostRecentlyUpdated"), Lucene.updated.name(), true));\r
+               sortChoices.add(new TicketSort(getString("gb.sortLeastRecentlyUpdated"), Lucene.updated.name(), false));\r
+               sortChoices.add(new TicketSort(getString("gb.sortMostComments"), Lucene.comments.name(), true));\r
+               sortChoices.add(new TicketSort(getString("gb.sortLeastComments"), Lucene.comments.name(), false));\r
+               sortChoices.add(new TicketSort(getString("gb.sortMostPatchsetRevisions"), Lucene.patchsets.name(), true));\r
+               sortChoices.add(new TicketSort(getString("gb.sortLeastPatchsetRevisions"), Lucene.patchsets.name(), false));\r
+               sortChoices.add(new TicketSort(getString("gb.sortMostVotes"), Lucene.votes.name(), true));\r
+               sortChoices.add(new TicketSort(getString("gb.sortLeastVotes"), Lucene.votes.name(), false));\r
+\r
+               TicketSort currentSort = sortChoices.get(0);\r
+               for (TicketSort ts : sortChoices) {\r
+                       if (ts.sortBy.equals(sortBy) && desc == ts.desc) {\r
+                               currentSort = ts;\r
+                               break;\r
+                       }\r
+               }\r
+               add(new Label("currentSort", currentSort.name));\r
+\r
+               ListDataProvider<TicketSort> sortChoicesDp = new ListDataProvider<TicketSort>(sortChoices);\r
+               DataView<TicketSort> sortMenu = new DataView<TicketSort>("sort", sortChoicesDp) {\r
+                       private static final long serialVersionUID = 1L;\r
+\r
+                       @Override\r
+                       public void populateItem(final Item<TicketSort> item) {\r
+                               final TicketSort ts = item.getModelObject();\r
+                               PageParameters params = queryParameters(queryParam, milestoneParam, statiiParam, assignedToParam, ts.sortBy, ts.desc, 1);\r
+                               item.add(new LinkPanel("sortLink", null, ts.name, TicketsPage.class, params).setRenderBodyOnly(true));\r
+                       }\r
+               };\r
+               add(sortMenu);\r
+\r
+\r
+               // paging links\r
+               buildPager(queryParam, milestoneParam, statiiParam, assignedToParam, sortBy, desc, page, pageSize, results.size(), totalResults);\r
+\r
+               ListDataProvider<QueryResult> resultsDataProvider = new ListDataProvider<QueryResult>(results);\r
+               DataView<QueryResult> ticketsView = new DataView<QueryResult>("ticket", resultsDataProvider) {\r
+                       private static final long serialVersionUID = 1L;\r
+\r
+                       @Override\r
+                       public void populateItem(final Item<QueryResult> item) {\r
+                               final QueryResult ticket = item.getModelObject();\r
+                               item.add(getStateIcon("state", ticket.type, ticket.status));\r
+                               item.add(new Label("id", "" + ticket.number));\r
+                               UserModel creator = app().users().getUserModel(ticket.createdBy);\r
+                               if (creator != null) {\r
+                                       item.add(new LinkPanel("createdBy", null, creator.getDisplayName(),\r
+                                               UserPage.class, WicketUtils.newUsernameParameter(ticket.createdBy)));\r
+                               } else {\r
+                                       item.add(new Label("createdBy", ticket.createdBy));\r
+                               }\r
+                               item.add(WicketUtils.createDateLabel("createDate", ticket.createdAt, GitBlitWebSession\r
+                                               .get().getTimezone(), getTimeUtils(), false));\r
+\r
+                               if (ticket.updatedAt == null) {\r
+                                       item.add(new Label("updated").setVisible(false));\r
+                               } else {\r
+                                       Fragment updated = new Fragment("updated", "updatedFragment", this);\r
+                                       UserModel updater = app().users().getUserModel(ticket.updatedBy);\r
+                                       if (updater != null) {\r
+                                               updated.add(new LinkPanel("updatedBy", null, updater.getDisplayName(),\r
+                                                               UserPage.class, WicketUtils.newUsernameParameter(ticket.updatedBy)));\r
+                                       } else {\r
+                                               updated.add(new Label("updatedBy", ticket.updatedBy));\r
+                                       }\r
+                                       updated.add(WicketUtils.createDateLabel("updateDate", ticket.updatedAt, GitBlitWebSession\r
+                                                       .get().getTimezone(), getTimeUtils(), false));\r
+                                       item.add(updated);\r
+                               }\r
+\r
+                               item.add(new LinkPanel("title", "list subject", StringUtils.trimString(\r
+                                               ticket.title, Constants.LEN_SHORTLOG), TicketsPage.class, newTicketParameter(ticket)));\r
+\r
+                               ListDataProvider<String> labelsProvider = new ListDataProvider<String>(ticket.getLabels());\r
+                               DataView<String> labelsView = new DataView<String>("labels", labelsProvider) {\r
+                                       private static final long serialVersionUID = 1L;\r
+\r
+                                       @Override\r
+                                       public void populateItem(final Item<String> labelItem) {\r
+                                               String content = messageProcessor().processPlainCommitMessage(getRepository(), repositoryName, labelItem.getModelObject());\r
+                                               Label label = new Label("label", content);\r
+                                               label.setEscapeModelStrings(false);\r
+                                               TicketLabel tLabel = app().tickets().getLabel(getRepositoryModel(), labelItem.getModelObject());\r
+                                               String background = MessageFormat.format("background-color:{0};", tLabel.color);\r
+                                               label.add(new SimpleAttributeModifier("style", background));\r
+                                               labelItem.add(label);\r
+                                       }\r
+                               };\r
+                               item.add(labelsView);\r
+\r
+                               if (StringUtils.isEmpty(ticket.responsible)) {\r
+                                       item.add(new Label("responsible").setVisible(false));\r
+                               } else {\r
+                                       UserModel responsible = app().users().getUserModel(ticket.responsible);\r
+                                       if (responsible == null) {\r
+                                               responsible = new UserModel(ticket.responsible);\r
+                                       }\r
+                                       GravatarImage avatar = new GravatarImage("responsible", responsible.getDisplayName(),\r
+                                                       responsible.emailAddress, null, 16, true);\r
+                                       avatar.setTooltip(getString("gb.responsible") + ": " + responsible.getDisplayName());\r
+                                       item.add(avatar);\r
+                               }\r
+\r
+                               // votes indicator\r
+                               Label v = new Label("votes", "" + ticket.votesCount);\r
+                               WicketUtils.setHtmlTooltip(v, getString("gb.votes"));\r
+                               item.add(v.setVisible(ticket.votesCount > 0));\r
+\r
+                               // watching indicator\r
+                               item.add(new Label("watching").setVisible(ticket.isWatching(GitBlitWebSession.get().getUsername())));\r
+\r
+                               // status indicator\r
+                               String css = getLozengeClass(ticket.status, true);\r
+                               Label l = new Label("status", ticket.status.toString());\r
+                               WicketUtils.setCssClass(l, css);\r
+                               item.add(l);\r
+\r
+                               // add the ticket indicators/icons\r
+                               List<Indicator> indicators = new ArrayList<Indicator>();\r
+\r
+                               // comments\r
+                               if (ticket.commentsCount > 0) {\r
+                                       int count = ticket.commentsCount;\r
+                                       String pattern = "gb.nComments";\r
+                                       if (count == 1) {\r
+                                               pattern = "gb.oneComment";\r
+                                       }\r
+                                       indicators.add(new Indicator("fa fa-comment", count, pattern));\r
+                               }\r
+\r
+                               // participants\r
+                               if (!ArrayUtils.isEmpty(ticket.participants)) {\r
+                                       int count = ticket.participants.size();\r
+                                       if (count > 1) {\r
+                                               String pattern = "gb.nParticipants";\r
+                                               indicators.add(new Indicator("fa fa-user", count, pattern));\r
+                                       }\r
+                               }\r
+\r
+                               // attachments\r
+                               if (!ArrayUtils.isEmpty(ticket.attachments)) {\r
+                                       int count = ticket.attachments.size();\r
+                                       String pattern = "gb.nAttachments";\r
+                                       if (count == 1) {\r
+                                               pattern = "gb.oneAttachment";\r
+                                       }\r
+                                       indicators.add(new Indicator("fa fa-file", count, pattern));\r
+                               }\r
+\r
+                               // patchset revisions\r
+                               if (ticket.patchset != null) {\r
+                                       int count = ticket.patchset.commits;\r
+                                       String pattern = "gb.nCommits";\r
+                                       if (count == 1) {\r
+                                               pattern = "gb.oneCommit";\r
+                                       }\r
+                                       indicators.add(new Indicator("fa fa-code", count, pattern));\r
+                               }\r
+\r
+                               // milestone\r
+                               if (!StringUtils.isEmpty(ticket.milestone)) {\r
+                                       indicators.add(new Indicator("fa fa-bullseye", ticket.milestone));\r
+                               }\r
+\r
+                               ListDataProvider<Indicator> indicatorsDp = new ListDataProvider<Indicator>(indicators);\r
+                               DataView<Indicator> indicatorsView = new DataView<Indicator>("indicators", indicatorsDp) {\r
+                                       private static final long serialVersionUID = 1L;\r
+\r
+                                       @Override\r
+                                       public void populateItem(final Item<Indicator> item) {\r
+                                               Indicator indicator = item.getModelObject();\r
+                                               String tooltip = indicator.getTooltip();\r
+\r
+                                               Label icon = new Label("icon");\r
+                                               WicketUtils.setCssClass(icon, indicator.css);\r
+                                               item.add(icon);\r
+\r
+                                               if (indicator.count > 0) {\r
+                                                       Label count = new Label("count", "" + indicator.count);\r
+                                                       item.add(count.setVisible(!StringUtils.isEmpty(tooltip)));\r
+                                               } else {\r
+                                                       item.add(new Label("count").setVisible(false));\r
+                                               }\r
+\r
+                                               WicketUtils.setHtmlTooltip(item, tooltip);\r
+                                       }\r
+                               };\r
+                               item.add(indicatorsView);\r
+                       }\r
+               };\r
+               add(ticketsView);\r
+\r
+               DataView<TicketMilestone> milestonesList = new DataView<TicketMilestone>("milestoneList", milestonesDp) {\r
+                       private static final long serialVersionUID = 1L;\r
+\r
+                       @Override\r
+                       public void populateItem(final Item<TicketMilestone> item) {\r
+                               final TicketMilestone tm = item.getModelObject();\r
+                               item.add(new Label("milestoneName", tm.name));\r
+                               item.add(new Label("milestoneState", tm.status.name()));\r
+                               item.add(new Label("milestoneDue", tm.due == null ? getString("gb.notSpecified") : tm.due.toString()));\r
+                       }\r
+               };\r
+               add(milestonesList);\r
+       }\r
+\r
+       protected PageParameters queryParameters(\r
+                       String query,\r
+                       String milestone,\r
+                       String[] states,\r
+                       String assignedTo,\r
+                       String sort,\r
+                       boolean descending,\r
+                       int page) {\r
+\r
+               PageParameters params = WicketUtils.newRepositoryParameter(repositoryName);\r
+               if (!StringUtils.isEmpty(query)) {\r
+                       params.add("q", query);\r
+               }\r
+               if (!StringUtils.isEmpty(milestone)) {\r
+                       params.add(Lucene.milestone.name(), milestone);\r
+               }\r
+               if (!ArrayUtils.isEmpty(states)) {\r
+                       for (String state : states) {\r
+                               params.add(Lucene.status.name(), state);\r
+                       }\r
+               }\r
+               if (!StringUtils.isEmpty(assignedTo)) {\r
+                       params.add(Lucene.responsible.name(), assignedTo);\r
+               }\r
+               if (!StringUtils.isEmpty(sort)) {\r
+                       params.add("sort", sort);\r
+               }\r
+               if (!descending) {\r
+                       params.add("direction", "asc");\r
+               }\r
+               if (page > 1) {\r
+                       params.add("pg", "" + page);\r
+               }\r
+               return params;\r
+       }\r
+\r
+       protected PageParameters newTicketParameter(QueryResult ticket) {\r
+               return WicketUtils.newObjectParameter(repositoryName, "" + ticket.number);\r
+       }\r
+\r
+       @Override\r
+       protected String getPageName() {\r
+               return getString("gb.tickets");\r
+       }\r
+\r
+       protected void buildPager(\r
+                       final String query,\r
+                       final String milestone,\r
+                       final String [] states,\r
+                       final String assignedTo,\r
+                       final String sort,\r
+                       final boolean desc,\r
+                       final int page,\r
+                       int pageSize,\r
+                       int count,\r
+                       int total) {\r
+\r
+               boolean showNav = total > (2 * pageSize);\r
+               boolean allowPrev = page > 1;\r
+               boolean allowNext = (pageSize * (page - 1) + count) < total;\r
+               add(new BookmarkablePageLink<Void>("prevLink", TicketsPage.class, queryParameters(query, milestone, states, assignedTo, sort, desc, page - 1)).setEnabled(allowPrev).setVisible(showNav));\r
+               add(new BookmarkablePageLink<Void>("nextLink", TicketsPage.class, queryParameters(query, milestone, states, assignedTo, sort, desc, page + 1)).setEnabled(allowNext).setVisible(showNav));\r
+\r
+               if (total <= pageSize) {\r
+                       add(new Label("pageLink").setVisible(false));\r
+                       return;\r
+               }\r
+\r
+               // determine page numbers to display\r
+               int pages = count == 0 ? 0 : ((total / pageSize) + (total % pageSize == 0 ? 0 : 1));\r
+               // preferred number of pagelinks\r
+               int segments = 5;\r
+               if (pages < segments) {\r
+                       // not enough data for preferred number of page links\r
+                       segments = pages;\r
+               }\r
+               int minpage = Math.min(Math.max(1, page - 2), pages - (segments - 1));\r
+               int maxpage = Math.min(pages, minpage + (segments - 1));\r
+               List<Integer> sequence = new ArrayList<Integer>();\r
+               for (int i = minpage; i <= maxpage; i++) {\r
+                       sequence.add(i);\r
+               }\r
+\r
+               ListDataProvider<Integer> pagesDp = new ListDataProvider<Integer>(sequence);\r
+               DataView<Integer> pagesView = new DataView<Integer>("pageLink", pagesDp) {\r
+                       private static final long serialVersionUID = 1L;\r
+\r
+                       @Override\r
+                       public void populateItem(final Item<Integer> item) {\r
+                               final Integer i = item.getModelObject();\r
+                               LinkPanel link = new LinkPanel("page", null, "" + i, TicketsPage.class, queryParameters(query, milestone, states, assignedTo, sort, desc, i));\r
+                               link.setRenderBodyOnly(true);\r
+                               if (i == page) {\r
+                                       WicketUtils.setCssClass(item, "active");\r
+                               }\r
+                               item.add(link);\r
+                       }\r
+               };\r
+               add(pagesView);\r
+       }\r
+\r
+       private class Indicator implements Serializable {\r
+\r
+               private static final long serialVersionUID = 1L;\r
+\r
+               final String css;\r
+               final int count;\r
+               final String tooltip;\r
+\r
+               Indicator(String css, String tooltip) {\r
+                       this.css = css;\r
+                       this.tooltip = tooltip;\r
+                       this.count = 0;\r
+               }\r
+\r
+               Indicator(String css, int count, String pattern) {\r
+                       this.css = css;\r
+                       this.count = count;\r
+                       this.tooltip = StringUtils.isEmpty(pattern) ? "" : MessageFormat.format(getString(pattern), count);\r
+               }\r
+\r
+               String getTooltip() {\r
+                       return tooltip;\r
+               }\r
+       }\r
+\r
+       private class TicketQuery implements Serializable, Comparable<TicketQuery> {\r
+\r
+               private static final long serialVersionUID = 1L;\r
+\r
+               final String name;\r
+               final String query;\r
+               String color;\r
+\r
+               TicketQuery(String name, String query) {\r
+                       this.name = name;\r
+                       this.query = query;\r
+               }\r
+\r
+               TicketQuery color(String value) {\r
+                       this.color = value;\r
+                       return this;\r
+               }\r
+\r
+               @Override\r
+               public boolean equals(Object o) {\r
+                       if (o instanceof TicketQuery) {\r
+                               return ((TicketQuery) o).query.equals(query);\r
+                       }\r
+                       return false;\r
+               }\r
+\r
+               @Override\r
+               public int hashCode() {\r
+                       return query.hashCode();\r
+               }\r
+\r
+               @Override\r
+               public int compareTo(TicketQuery o) {\r
+                       return query.compareTo(o.query);\r
+               }\r
+       }\r
+\r
+       private class TicketSort implements Serializable {\r
+\r
+               private static final long serialVersionUID = 1L;\r
+\r
+               final String name;\r
+               final String sortBy;\r
+               final boolean desc;\r
+\r
+               TicketSort(String name, String sortBy, boolean desc) {\r
+                       this.name = name;\r
+                       this.sortBy = sortBy;\r
+                       this.desc = desc;\r
+               }\r
+       }\r
+\r
+       private class TicketSearchForm extends SessionlessForm<Void> implements Serializable {\r
+               private static final long serialVersionUID = 1L;\r
+\r
+               private final String repositoryName;\r
+\r
+               private final IModel<String> searchBoxModel;;\r
+\r
+               public TicketSearchForm(String id, String repositoryName, String text) {\r
+                       super(id, TicketsPage.this.getClass(), TicketsPage.this.getPageParameters());\r
+\r
+                       this.repositoryName = repositoryName;\r
+                       this.searchBoxModel = new Model<String>(text == null ? "" : text);\r
+\r
+                       TextField<String> searchBox = new TextField<String>("ticketSearchBox", searchBoxModel);\r
+                       add(searchBox);\r
+               }\r
+\r
+               void setTranslatedAttributes() {\r
+                       WicketUtils.setHtmlTooltip(get("ticketSearchBox"),\r
+                                       MessageFormat.format(getString("gb.searchTicketsTooltip"), repositoryName));\r
+                       WicketUtils.setInputPlaceholder(get("ticketSearchBox"), getString("gb.searchTickets"));\r
+               }\r
+\r
+               @Override\r
+               public void onSubmit() {\r
+                       String searchString = searchBoxModel.getObject();\r
+                       if (StringUtils.isEmpty(searchString)) {\r
+                               // redirect to self to avoid wicket page update bug\r
+                               String absoluteUrl = getCanonicalUrl();\r
+                               getRequestCycle().setRequestTarget(new RedirectRequestTarget(absoluteUrl));\r
+                               return;\r
+                       }\r
+\r
+                       // use an absolute url to workaround Wicket-Tomcat problems with\r
+                       // mounted url parameters (issue-111)\r
+                       PageParameters params = WicketUtils.newRepositoryParameter(repositoryName);\r
+                       params.add("s", searchString);\r
+                       String absoluteUrl = getCanonicalUrl(TicketsPage.class, params);\r
+                       getRequestCycle().setRequestTarget(new RedirectRequestTarget(absoluteUrl));\r
+               }\r
+       }\r
+}\r
diff --git a/src/main/java/com/gitblit/wicket/pages/propose_git.md b/src/main/java/com/gitblit/wicket/pages/propose_git.md
new file mode 100644 (file)
index 0000000..1b4f429
--- /dev/null
@@ -0,0 +1,6 @@
+    git clone ${url}
+    cd ${repo}
+    git checkout -b ${reviewBranch} ${integrationBranch}
+    ...
+    git push origin HEAD:refs/for/${ticketId}
+    git branch --set-upstream-to=origin/${reviewBranch}
diff --git a/src/main/java/com/gitblit/wicket/pages/propose_pt.md b/src/main/java/com/gitblit/wicket/pages/propose_pt.md
new file mode 100644 (file)
index 0000000..949d236
--- /dev/null
@@ -0,0 +1,5 @@
+    git clone ${url}
+    cd ${repo}
+    pt start ${ticketId}
+    ...
+    pt propose
diff --git a/src/main/java/com/gitblit/wicket/panels/CommentPanel.html b/src/main/java/com/gitblit/wicket/panels/CommentPanel.html
new file mode 100644 (file)
index 0000000..1fdfb16
--- /dev/null
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<html xmlns="http://www.w3.org/1999/xhtml" xmlns:wicket="http://wicket.apache.org/dtds.data/wicket-xhtml1.4-strict.dtd">
+<wicket:panel>
+       <div style="border: 1px solid #ccc;">
+       
+               <ul class="nav nav-pills" style="margin: 2px 5px !important">
+                       <li class="active"><a href="#write" data-toggle="tab"><wicket:message key="gb.write">[write]</wicket:message></a></li>
+                       <li><a href="#preview" data-toggle="tab"><wicket:message key="gb.preview">[preview]</wicket:message></a></li>
+               </ul>
+               <div class="tab-content">
+                       <div class="tab-pane active" id="write">
+                               <textarea class="span7" style="height:7em;border-color:#ccc;border-right:0px;border-left:0px;border-radius:0px;box-shadow: none;" wicket:id="markdownEditor"></textarea>
+                       </div>
+                       <div class="tab-pane" id="preview">
+                               <div class="preview" style="height:7em;border:1px solid #ccc;border-right:0px;border-left:0px;margin-bottom:9px;padding:4px;background-color:#ffffff;">
+                                       <div class="markdown" wicket:id="markdownPreview"></div>
+                               </div>
+                       </div>
+               </div>
+                               
+               <div style="text-align:right;padding-right:5px;">
+                       <form style="margin-bottom:9px;" wicket:id="editorForm" action="">
+                               <input class="btn btn-appmenu" type="submit" wicket:id="submit" value="comment"></input>
+                       </form>
+               </div>
+               
+       </div>
+</wicket:panel>
+</html>
\ No newline at end of file
diff --git a/src/main/java/com/gitblit/wicket/panels/CommentPanel.java b/src/main/java/com/gitblit/wicket/panels/CommentPanel.java
new file mode 100644 (file)
index 0000000..1d49ff0
--- /dev/null
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2013 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.wicket.panels;
+
+import org.apache.wicket.ajax.AjaxRequestTarget;
+import org.apache.wicket.ajax.markup.html.form.AjaxButton;
+import org.apache.wicket.markup.html.basic.Label;
+import org.apache.wicket.markup.html.form.Form;
+import org.apache.wicket.model.IModel;
+import org.apache.wicket.model.Model;
+
+import com.gitblit.models.RepositoryModel;
+import com.gitblit.models.TicketModel;
+import com.gitblit.models.TicketModel.Change;
+import com.gitblit.models.UserModel;
+import com.gitblit.wicket.WicketUtils;
+import com.gitblit.wicket.pages.BasePage;
+
+public class CommentPanel extends BasePanel {
+       private static final long serialVersionUID = 1L;
+
+       final UserModel user;
+
+       final TicketModel ticket;
+
+       final Change change;
+
+       final Class<? extends BasePage> pageClass;
+
+       private MarkdownTextArea markdownEditor;
+
+       private Label markdownPreview;
+
+       private String repositoryName;
+
+       public CommentPanel(String id, final UserModel user, final TicketModel ticket,
+                       final Change change, final Class<? extends BasePage> pageClass) {
+               super(id);
+               this.user = user;
+               this.ticket = ticket;
+               this.change = change;
+               this.pageClass = pageClass;
+       }
+
+       @Override
+       protected void onInitialize() {
+               super.onInitialize();
+
+               Form<String> form = new Form<String>("editorForm");
+               add(form);
+
+               form.add(new AjaxButton("submit", new Model<String>(getString("gb.comment")), form) {
+                       private static final long serialVersionUID = 1L;
+
+                       @Override
+                       public void onSubmit(AjaxRequestTarget target, Form<?> form) {
+                               String txt = markdownEditor.getText();
+                               if (change == null) {
+                                       // new comment
+                                       Change newComment = new Change(user.username);
+                                       newComment.comment(txt);
+                                       if (!ticket.isWatching(user.username)) {
+                                               newComment.watch(user.username);
+                                       }
+                                       RepositoryModel repository = app().repositories().getRepositoryModel(ticket.repository);
+                                       TicketModel updatedTicket = app().tickets().updateTicket(repository, ticket.number, newComment);
+                                       if (updatedTicket != null) {
+                                               app().tickets().createNotifier().sendMailing(updatedTicket);
+                                               setResponsePage(pageClass, WicketUtils.newObjectParameter(updatedTicket.repository, "" + ticket.number));
+                                       } else {
+                                               error("Failed to add comment!");
+                                       }
+                               } else {
+                                       // TODO update comment
+                               }
+                       }
+               }.setVisible(ticket != null && ticket.number > 0));
+
+               final IModel<String> markdownPreviewModel = new Model<String>();
+               markdownPreview = new Label("markdownPreview", markdownPreviewModel);
+               markdownPreview.setEscapeModelStrings(false);
+               markdownPreview.setOutputMarkupId(true);
+               add(markdownPreview);
+
+               markdownEditor = new MarkdownTextArea("markdownEditor", markdownPreviewModel, markdownPreview);
+               markdownEditor.setRepository(repositoryName);
+               WicketUtils.setInputPlaceholder(markdownEditor, getString("gb.leaveComment"));
+               add(markdownEditor);
+       }
+
+       public void setRepository(String repositoryName) {
+               this.repositoryName = repositoryName;
+               if (markdownEditor != null) {
+                       markdownEditor.setRepository(repositoryName);
+               }
+       }
+}
\ No newline at end of file
index de09aa9510eef39e3e88a5541f2f9d2c366b2061..decfda5052b7da131eae977438a9b1355ce510cb 100644 (file)
-/*\r
- * Copyright 2013 gitblit.com.\r
- *\r
- * Licensed under the Apache License, Version 2.0 (the "License");\r
- * you may not use this file except in compliance with the License.\r
- * You may obtain a copy of the License at\r
- *\r
- *     http://www.apache.org/licenses/LICENSE-2.0\r
- *\r
- * Unless required by applicable law or agreed to in writing, software\r
- * distributed under the License is distributed on an "AS IS" BASIS,\r
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r
- * See the License for the specific language governing permissions and\r
- * limitations under the License.\r
- */\r
-package com.gitblit.wicket.panels;\r
-\r
-import java.text.DateFormat;\r
-import java.text.MessageFormat;\r
-import java.text.SimpleDateFormat;\r
-import java.util.ArrayList;\r
-import java.util.Date;\r
-import java.util.List;\r
-import java.util.TimeZone;\r
-\r
-import org.apache.wicket.markup.html.basic.Label;\r
-import org.apache.wicket.markup.repeater.Item;\r
-import org.apache.wicket.markup.repeater.data.DataView;\r
-import org.apache.wicket.markup.repeater.data.ListDataProvider;\r
-import org.eclipse.jgit.lib.PersonIdent;\r
-\r
-import com.gitblit.Constants;\r
-import com.gitblit.Keys;\r
-import com.gitblit.models.DailyLogEntry;\r
-import com.gitblit.models.RepositoryCommit;\r
-import com.gitblit.utils.StringUtils;\r
-import com.gitblit.utils.TimeUtils;\r
-import com.gitblit.wicket.WicketUtils;\r
-import com.gitblit.wicket.pages.CommitPage;\r
-import com.gitblit.wicket.pages.ComparePage;\r
-import com.gitblit.wicket.pages.SummaryPage;\r
-import com.gitblit.wicket.pages.TagPage;\r
-import com.gitblit.wicket.pages.TreePage;\r
-\r
-public class DigestsPanel extends BasePanel {\r
-\r
-       private static final long serialVersionUID = 1L;\r
-\r
-       private final boolean hasChanges;\r
-\r
-       private boolean hasMore;\r
-\r
-       public DigestsPanel(String wicketId, List<DailyLogEntry> digests) {\r
-               super(wicketId);\r
-               hasChanges = digests.size() > 0;\r
-\r
-               ListDataProvider<DailyLogEntry> dp = new ListDataProvider<DailyLogEntry>(digests);\r
-               DataView<DailyLogEntry> pushView = new DataView<DailyLogEntry>("change", dp) {\r
-                       private static final long serialVersionUID = 1L;\r
-\r
-                       @Override\r
-                       public void populateItem(final Item<DailyLogEntry> logItem) {\r
-                               final DailyLogEntry change = logItem.getModelObject();\r
-\r
-                               String dateFormat = app().settings().getString(Keys.web.datestampLongFormat, "EEEE, MMMM d, yyyy");\r
-                               TimeZone timezone = getTimeZone();\r
-                               DateFormat df = new SimpleDateFormat(dateFormat);\r
-                               df.setTimeZone(timezone);\r
-\r
-                               String fullRefName = change.getChangedRefs().get(0);\r
-                               String shortRefName = fullRefName;\r
-                               boolean isTag = false;\r
-                               if (shortRefName.startsWith(Constants.R_HEADS)) {\r
-                                       shortRefName = shortRefName.substring(Constants.R_HEADS.length());\r
-                               } else if (shortRefName.startsWith(Constants.R_TAGS)) {\r
-                                       shortRefName = shortRefName.substring(Constants.R_TAGS.length());\r
-                                       isTag = true;\r
-                               }\r
-\r
-                               String fuzzydate;\r
-                               TimeUtils tu = getTimeUtils();\r
-                               Date pushDate = change.date;\r
-                               if (TimeUtils.isToday(pushDate, timezone)) {\r
-                                       fuzzydate = tu.today();\r
-                               } else if (TimeUtils.isYesterday(pushDate, timezone)) {\r
-                                       fuzzydate = tu.yesterday();\r
-                               } else {\r
-                                       fuzzydate = getTimeUtils().timeAgo(pushDate);\r
-                               }\r
-                               logItem.add(new Label("whenChanged", fuzzydate + ", " + df.format(pushDate)));\r
-\r
-                               Label changeIcon = new Label("changeIcon");\r
-                               // use the repository hash color to differentiate the icon.\r
-                String color = StringUtils.getColor(StringUtils.stripDotGit(change.repository));\r
-                WicketUtils.setCssStyle(changeIcon, "color: " + color);\r
-\r
-                               if (isTag) {\r
-                                       WicketUtils.setCssClass(changeIcon, "iconic-tag");\r
-                               } else {\r
-                                       WicketUtils.setCssClass(changeIcon, "iconic-loop");\r
-                               }\r
-                               logItem.add(changeIcon);\r
-\r
-                if (isTag) {\r
-                       // tags are special\r
-                       PersonIdent ident = change.getCommits().get(0).getAuthorIdent();\r
-                       if (!StringUtils.isEmpty(ident.getName())) {\r
-                               logItem.add(new Label("whoChanged", ident.getName()));\r
-                       } else {\r
-                               logItem.add(new Label("whoChanged", ident.getEmailAddress()));\r
-                       }\r
-                } else {\r
-                       logItem.add(new Label("whoChanged").setVisible(false));\r
-                }\r
-\r
-                               String preposition = "gb.of";\r
-                               boolean isDelete = false;\r
-                               String what;\r
-                               String by = null;\r
-                               switch(change.getChangeType(fullRefName)) {\r
-                               case CREATE:\r
-                                       if (isTag) {\r
-                                               // new tag\r
-                                               what = getString("gb.createdNewTag");\r
-                                               preposition = "gb.in";\r
-                                       } else {\r
-                                               // new branch\r
-                                               what = getString("gb.createdNewBranch");\r
-                                               preposition = "gb.in";\r
-                                       }\r
-                                       break;\r
-                               case DELETE:\r
-                                       isDelete = true;\r
-                                       if (isTag) {\r
-                                               what = getString("gb.deletedTag");\r
-                                       } else {\r
-                                               what = getString("gb.deletedBranch");\r
-                                       }\r
-                                       preposition = "gb.from";\r
-                                       break;\r
-                               default:\r
-                                       what = MessageFormat.format(change.getCommitCount() > 1 ? getString("gb.commitsTo") : getString("gb.oneCommitTo"), change.getCommitCount());\r
-\r
-                                       if (change.getAuthorCount() == 1) {\r
-                                               by = MessageFormat.format(getString("gb.byOneAuthor"), change.getAuthorIdent().getName());\r
-                                       } else {\r
-                                               by = MessageFormat.format(getString("gb.byNAuthors"), change.getAuthorCount());\r
-                                       }\r
-                                       break;\r
-                               }\r
-                               logItem.add(new Label("whatChanged", what));\r
-                               logItem.add(new Label("byAuthors", by).setVisible(!StringUtils.isEmpty(by)));\r
-\r
-                               if (isDelete) {\r
-                                       // can't link to deleted ref\r
-                                       logItem.add(new Label("refChanged", shortRefName));\r
-                               } else if (isTag) {\r
-                                       // link to tag\r
-                                       logItem.add(new LinkPanel("refChanged", null, shortRefName,\r
-                                                       TagPage.class, WicketUtils.newObjectParameter(change.repository, fullRefName)));\r
-                               } else {\r
-                                       // link to tree\r
-                                       logItem.add(new LinkPanel("refChanged", null, shortRefName,\r
-                                               TreePage.class, WicketUtils.newObjectParameter(change.repository, fullRefName)));\r
-                               }\r
-\r
-                               // to/from/etc\r
-                               logItem.add(new Label("repoPreposition", getString(preposition)));\r
-                               String repoName = StringUtils.stripDotGit(change.repository);\r
-                               logItem.add(new LinkPanel("repoChanged", null, repoName,\r
-                                               SummaryPage.class, WicketUtils.newRepositoryParameter(change.repository)));\r
-\r
-                               int maxCommitCount = 5;\r
-                               List<RepositoryCommit> commits = change.getCommits();\r
-                               if (commits.size() > maxCommitCount) {\r
-                                       commits = new ArrayList<RepositoryCommit>(commits.subList(0,  maxCommitCount));\r
-                               }\r
-\r
-                               // compare link\r
-                               String compareLinkText = null;\r
-                               if ((change.getCommitCount() <= maxCommitCount) && (change.getCommitCount() > 1)) {\r
-                                       compareLinkText = MessageFormat.format(getString("gb.viewComparison"), commits.size());\r
-                               } else if (change.getCommitCount() > maxCommitCount) {\r
-                                       int diff = change.getCommitCount() - maxCommitCount;\r
-                                       compareLinkText = MessageFormat.format(diff > 1 ? getString("gb.nMoreCommits") : getString("gb.oneMoreCommit"), diff);\r
-                               }\r
-                               if (StringUtils.isEmpty(compareLinkText)) {\r
-                                       logItem.add(new Label("compareLink").setVisible(false));\r
-                               } else {\r
-                                       String endRangeId = change.getNewId(fullRefName);\r
-                                       String startRangeId = change.getOldId(fullRefName);\r
-                                       logItem.add(new LinkPanel("compareLink", null, compareLinkText, ComparePage.class, WicketUtils.newRangeParameter(change.repository, startRangeId, endRangeId)));\r
-                               }\r
-\r
-                               final boolean showSwatch = app().settings().getBoolean(Keys.web.repositoryListSwatches, true);\r
-\r
-                               ListDataProvider<RepositoryCommit> cdp = new ListDataProvider<RepositoryCommit>(commits);\r
-                               DataView<RepositoryCommit> commitsView = new DataView<RepositoryCommit>("commit", cdp) {\r
-                                       private static final long serialVersionUID = 1L;\r
-\r
-                                       @Override\r
-                                       public void populateItem(final Item<RepositoryCommit> commitItem) {\r
-                                               final RepositoryCommit commit = commitItem.getModelObject();\r
-\r
-                                               // author gravatar\r
-                                               commitItem.add(new GravatarImage("commitAuthor", commit.getAuthorIdent(), null, 16, false));\r
-\r
-                                               // merge icon\r
-                                               if (commit.getParentCount() > 1) {\r
-                                                       commitItem.add(WicketUtils.newImage("commitIcon", "commit_merge_16x16.png"));\r
-                                               } else {\r
-                                                       commitItem.add(WicketUtils.newBlankImage("commitIcon"));\r
-                                               }\r
-\r
-                                               // short message\r
-                                               String shortMessage = commit.getShortMessage();\r
-                                               String trimmedMessage = shortMessage;\r
-                                               if (commit.getRefs() != null && commit.getRefs().size() > 0) {\r
-                                                       trimmedMessage = StringUtils.trimString(shortMessage, Constants.LEN_SHORTLOG_REFS);\r
-                                               } else {\r
-                                                       trimmedMessage = StringUtils.trimString(shortMessage, Constants.LEN_SHORTLOG);\r
-                                               }\r
-                                               LinkPanel shortlog = new LinkPanel("commitShortMessage", "list",\r
-                                                               trimmedMessage, CommitPage.class, WicketUtils.newObjectParameter(\r
-                                                                               change.repository, commit.getName()));\r
-                                               if (!shortMessage.equals(trimmedMessage)) {\r
-                                                       WicketUtils.setHtmlTooltip(shortlog, shortMessage);\r
-                                               }\r
-                                               commitItem.add(shortlog);\r
-\r
-                                               // commit hash link\r
-                                               int hashLen = app().settings().getInteger(Keys.web.shortCommitIdLength, 6);\r
-                                               LinkPanel commitHash = new LinkPanel("hashLink", null, commit.getName().substring(0, hashLen),\r
-                                                               CommitPage.class, WicketUtils.newObjectParameter(\r
-                                                                               change.repository, commit.getName()));\r
-                                               WicketUtils.setCssClass(commitHash, "shortsha1");\r
-                                               WicketUtils.setHtmlTooltip(commitHash, commit.getName());\r
-                                               commitItem.add(commitHash);\r
-\r
-                                               if (showSwatch) {\r
-                                                       // set repository color\r
-                                                       String color = StringUtils.getColor(StringUtils.stripDotGit(change.repository));\r
-                                                       WicketUtils.setCssStyle(commitItem, MessageFormat.format("border-left: 2px solid {0};", color));\r
-                                               }\r
-                                       }\r
-                               };\r
-\r
-                               logItem.add(commitsView);\r
-                       }\r
-               };\r
-\r
-               add(pushView);\r
-       }\r
-\r
-       public boolean hasMore() {\r
-               return hasMore;\r
-       }\r
-\r
-       public boolean hideIfEmpty() {\r
-               setVisible(hasChanges);\r
-               return hasChanges;\r
-       }\r
-}\r
+/*
+ * Copyright 2013 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.wicket.panels;
+
+import java.text.DateFormat;
+import java.text.MessageFormat;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.TimeZone;
+
+import org.apache.wicket.markup.html.basic.Label;
+import org.apache.wicket.markup.repeater.Item;
+import org.apache.wicket.markup.repeater.data.DataView;
+import org.apache.wicket.markup.repeater.data.ListDataProvider;
+import org.eclipse.jgit.lib.PersonIdent;
+
+import com.gitblit.Constants;
+import com.gitblit.Keys;
+import com.gitblit.models.DailyLogEntry;
+import com.gitblit.models.RepositoryCommit;
+import com.gitblit.utils.StringUtils;
+import com.gitblit.utils.TimeUtils;
+import com.gitblit.wicket.WicketUtils;
+import com.gitblit.wicket.pages.CommitPage;
+import com.gitblit.wicket.pages.ComparePage;
+import com.gitblit.wicket.pages.SummaryPage;
+import com.gitblit.wicket.pages.TagPage;
+import com.gitblit.wicket.pages.TicketsPage;
+import com.gitblit.wicket.pages.TreePage;
+
+public class DigestsPanel extends BasePanel {
+
+       private static final long serialVersionUID = 1L;
+
+       private final boolean hasChanges;
+
+       private boolean hasMore;
+
+       public DigestsPanel(String wicketId, List<DailyLogEntry> digests) {
+               super(wicketId);
+               hasChanges = digests.size() > 0;
+
+               ListDataProvider<DailyLogEntry> dp = new ListDataProvider<DailyLogEntry>(digests);
+               DataView<DailyLogEntry> pushView = new DataView<DailyLogEntry>("change", dp) {
+                       private static final long serialVersionUID = 1L;
+
+                       @Override
+                       public void populateItem(final Item<DailyLogEntry> logItem) {
+                               final DailyLogEntry change = logItem.getModelObject();
+
+                               String dateFormat = app().settings().getString(Keys.web.datestampLongFormat, "EEEE, MMMM d, yyyy");
+                               TimeZone timezone = getTimeZone();
+                               DateFormat df = new SimpleDateFormat(dateFormat);
+                               df.setTimeZone(timezone);
+
+                               String fullRefName = change.getChangedRefs().get(0);
+                               String shortRefName = fullRefName;
+                               String ticketId = "";
+                               boolean isTag = false;
+                               boolean isTicket = false;
+                               if (shortRefName.startsWith(Constants.R_TICKET)) {
+                                       ticketId = shortRefName = shortRefName.substring(Constants.R_TICKET.length());
+                                       shortRefName = MessageFormat.format(getString("gb.ticketN"), ticketId);
+                                       isTicket = true;
+                               } else if (shortRefName.startsWith(Constants.R_HEADS)) {
+                                       shortRefName = shortRefName.substring(Constants.R_HEADS.length());
+                               } else if (shortRefName.startsWith(Constants.R_TAGS)) {
+                                       shortRefName = shortRefName.substring(Constants.R_TAGS.length());
+                                       isTag = true;
+                               }
+
+                               String fuzzydate;
+                               TimeUtils tu = getTimeUtils();
+                               Date pushDate = change.date;
+                               if (TimeUtils.isToday(pushDate, timezone)) {
+                                       fuzzydate = tu.today();
+                               } else if (TimeUtils.isYesterday(pushDate, timezone)) {
+                                       fuzzydate = tu.yesterday();
+                               } else {
+                                       fuzzydate = getTimeUtils().timeAgo(pushDate);
+                               }
+                               logItem.add(new Label("whenChanged", fuzzydate + ", " + df.format(pushDate)));
+
+                               Label changeIcon = new Label("changeIcon");
+                               // use the repository hash color to differentiate the icon.
+                String color = StringUtils.getColor(StringUtils.stripDotGit(change.repository));
+                WicketUtils.setCssStyle(changeIcon, "color: " + color);
+
+                               if (isTag) {
+                                       WicketUtils.setCssClass(changeIcon, "iconic-tag");
+                               } else if (isTicket) {
+                                       WicketUtils.setCssClass(changeIcon, "fa fa-ticket");
+                               } else {
+                                       WicketUtils.setCssClass(changeIcon, "iconic-loop");
+                               }
+                               logItem.add(changeIcon);
+
+                if (isTag) {
+                       // tags are special
+                       PersonIdent ident = change.getCommits().get(0).getAuthorIdent();
+                       if (!StringUtils.isEmpty(ident.getName())) {
+                               logItem.add(new Label("whoChanged", ident.getName()));
+                       } else {
+                               logItem.add(new Label("whoChanged", ident.getEmailAddress()));
+                       }
+                } else {
+                       logItem.add(new Label("whoChanged").setVisible(false));
+                }
+
+                               String preposition = "gb.of";
+                               boolean isDelete = false;
+                               String what;
+                               String by = null;
+                               switch(change.getChangeType(fullRefName)) {
+                               case CREATE:
+                                       if (isTag) {
+                                               // new tag
+                                               what = getString("gb.createdNewTag");
+                                               preposition = "gb.in";
+                                       } else {
+                                               // new branch
+                                               what = getString("gb.createdNewBranch");
+                                               preposition = "gb.in";
+                                       }
+                                       break;
+                               case DELETE:
+                                       isDelete = true;
+                                       if (isTag) {
+                                               what = getString("gb.deletedTag");
+                                       } else {
+                                               what = getString("gb.deletedBranch");
+                                       }
+                                       preposition = "gb.from";
+                                       break;
+                               default:
+                                       what = MessageFormat.format(change.getCommitCount() > 1 ? getString("gb.commitsTo") : getString("gb.oneCommitTo"), change.getCommitCount());
+
+                                       if (change.getAuthorCount() == 1) {
+                                               by = MessageFormat.format(getString("gb.byOneAuthor"), change.getAuthorIdent().getName());
+                                       } else {
+                                               by = MessageFormat.format(getString("gb.byNAuthors"), change.getAuthorCount());
+                                       }
+                                       break;
+                               }
+                               logItem.add(new Label("whatChanged", what));
+                               logItem.add(new Label("byAuthors", by).setVisible(!StringUtils.isEmpty(by)));
+
+                               if (isDelete) {
+                                       // can't link to deleted ref
+                                       logItem.add(new Label("refChanged", shortRefName));
+                               } else if (isTag) {
+                                       // link to tag
+                                       logItem.add(new LinkPanel("refChanged", null, shortRefName,
+                                                       TagPage.class, WicketUtils.newObjectParameter(change.repository, fullRefName)));
+                               } else if (isTicket) {
+                                       // link to ticket
+                                       logItem.add(new LinkPanel("refChanged", null, shortRefName,
+                                                       TicketsPage.class, WicketUtils.newObjectParameter(change.repository, ticketId)));
+                               } else {
+                                       // link to tree
+                                       logItem.add(new LinkPanel("refChanged", null, shortRefName,
+                                               TreePage.class, WicketUtils.newObjectParameter(change.repository, fullRefName)));
+                               }
+
+                               // to/from/etc
+                               logItem.add(new Label("repoPreposition", getString(preposition)));
+                               String repoName = StringUtils.stripDotGit(change.repository);
+                               logItem.add(new LinkPanel("repoChanged", null, repoName,
+                                               SummaryPage.class, WicketUtils.newRepositoryParameter(change.repository)));
+
+                               int maxCommitCount = 5;
+                               List<RepositoryCommit> commits = change.getCommits();
+                               if (commits.size() > maxCommitCount) {
+                                       commits = new ArrayList<RepositoryCommit>(commits.subList(0,  maxCommitCount));
+                               }
+
+                               // compare link
+                               String compareLinkText = null;
+                               if ((change.getCommitCount() <= maxCommitCount) && (change.getCommitCount() > 1)) {
+                                       compareLinkText = MessageFormat.format(getString("gb.viewComparison"), commits.size());
+                               } else if (change.getCommitCount() > maxCommitCount) {
+                                       int diff = change.getCommitCount() - maxCommitCount;
+                                       compareLinkText = MessageFormat.format(diff > 1 ? getString("gb.nMoreCommits") : getString("gb.oneMoreCommit"), diff);
+                               }
+                               if (StringUtils.isEmpty(compareLinkText)) {
+                                       logItem.add(new Label("compareLink").setVisible(false));
+                               } else {
+                                       String endRangeId = change.getNewId(fullRefName);
+                                       String startRangeId = change.getOldId(fullRefName);
+                                       logItem.add(new LinkPanel("compareLink", null, compareLinkText, ComparePage.class, WicketUtils.newRangeParameter(change.repository, startRangeId, endRangeId)));
+                               }
+
+                               final boolean showSwatch = app().settings().getBoolean(Keys.web.repositoryListSwatches, true);
+
+                               ListDataProvider<RepositoryCommit> cdp = new ListDataProvider<RepositoryCommit>(commits);
+                               DataView<RepositoryCommit> commitsView = new DataView<RepositoryCommit>("commit", cdp) {
+                                       private static final long serialVersionUID = 1L;
+
+                                       @Override
+                                       public void populateItem(final Item<RepositoryCommit> commitItem) {
+                                               final RepositoryCommit commit = commitItem.getModelObject();
+
+                                               // author gravatar
+                                               commitItem.add(new GravatarImage("commitAuthor", commit.getAuthorIdent(), null, 16, false));
+
+                                               // merge icon
+                                               if (commit.getParentCount() > 1) {
+                                                       commitItem.add(WicketUtils.newImage("commitIcon", "commit_merge_16x16.png"));
+                                               } else {
+                                                       commitItem.add(WicketUtils.newBlankImage("commitIcon"));
+                                               }
+
+                                               // short message
+                                               String shortMessage = commit.getShortMessage();
+                                               String trimmedMessage = shortMessage;
+                                               if (commit.getRefs() != null && commit.getRefs().size() > 0) {
+                                                       trimmedMessage = StringUtils.trimString(shortMessage, Constants.LEN_SHORTLOG_REFS);
+                                               } else {
+                                                       trimmedMessage = StringUtils.trimString(shortMessage, Constants.LEN_SHORTLOG);
+                                               }
+                                               LinkPanel shortlog = new LinkPanel("commitShortMessage", "list",
+                                                               trimmedMessage, CommitPage.class, WicketUtils.newObjectParameter(
+                                                                               change.repository, commit.getName()));
+                                               if (!shortMessage.equals(trimmedMessage)) {
+                                                       WicketUtils.setHtmlTooltip(shortlog, shortMessage);
+                                               }
+                                               commitItem.add(shortlog);
+
+                                               // commit hash link
+                                               int hashLen = app().settings().getInteger(Keys.web.shortCommitIdLength, 6);
+                                               LinkPanel commitHash = new LinkPanel("hashLink", null, commit.getName().substring(0, hashLen),
+                                                               CommitPage.class, WicketUtils.newObjectParameter(
+                                                                               change.repository, commit.getName()));
+                                               WicketUtils.setCssClass(commitHash, "shortsha1");
+                                               WicketUtils.setHtmlTooltip(commitHash, commit.getName());
+                                               commitItem.add(commitHash);
+
+                                               if (showSwatch) {
+                                                       // set repository color
+                                                       String color = StringUtils.getColor(StringUtils.stripDotGit(change.repository));
+                                                       WicketUtils.setCssStyle(commitItem, MessageFormat.format("border-left: 2px solid {0};", color));
+                                               }
+                                       }
+                               };
+
+                               logItem.add(commitsView);
+                       }
+               };
+
+               add(pushView);
+       }
+
+       public boolean hasMore() {
+               return hasMore;
+       }
+
+       public boolean hideIfEmpty() {
+               setVisible(hasChanges);
+               return hasChanges;
+       }
+}
index 9507a25e085b414a41fdd04ddc284620d13aea7c..e4157577728fecca93d51eae466cc7f4d3543c7e 100644 (file)
@@ -1,70 +1,74 @@
-/*\r
- * Copyright 2011 gitblit.com.\r
- *\r
- * Licensed under the Apache License, Version 2.0 (the "License");\r
- * you may not use this file except in compliance with the License.\r
- * You may obtain a copy of the License at\r
- *\r
- *     http://www.apache.org/licenses/LICENSE-2.0\r
- *\r
- * Unless required by applicable law or agreed to in writing, software\r
- * distributed under the License is distributed on an "AS IS" BASIS,\r
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r
- * See the License for the specific language governing permissions and\r
- * limitations under the License.\r
- */\r
-package com.gitblit.wicket.panels;\r
-\r
-import org.eclipse.jgit.lib.PersonIdent;\r
-\r
-import com.gitblit.Keys;\r
-import com.gitblit.models.UserModel;\r
-import com.gitblit.utils.ActivityUtils;\r
-import com.gitblit.wicket.ExternalImage;\r
-import com.gitblit.wicket.WicketUtils;\r
-\r
-/**\r
- * Represents a Gravatar image.\r
- *\r
- * @author James Moger\r
- *\r
- */\r
-public class GravatarImage extends BasePanel {\r
-\r
-       private static final long serialVersionUID = 1L;\r
-\r
-       public GravatarImage(String id, PersonIdent person) {\r
-               this(id, person, 0);\r
-       }\r
-\r
-       public GravatarImage(String id, PersonIdent person, int width) {\r
-               this(id, person.getName(), person.getEmailAddress(), "gravatar", width, true);\r
-       }\r
-\r
-       public GravatarImage(String id, PersonIdent person, String cssClass, int width, boolean identicon) {\r
-               this(id, person.getName(), person.getEmailAddress(), cssClass, width, identicon);\r
-       }\r
-\r
-       public GravatarImage(String id, UserModel user, String cssClass, int width, boolean identicon) {\r
-               this(id, user.getDisplayName(), user.emailAddress, cssClass, width, identicon);\r
-       }\r
-\r
-       public GravatarImage(String id, String username, String emailaddress, String cssClass, int width, boolean identicon) {\r
-               super(id);\r
-\r
-               String email = emailaddress == null ? username.toLowerCase() : emailaddress.toLowerCase();\r
-               String url;\r
-               if (identicon) {\r
-                       url = ActivityUtils.getGravatarIdenticonUrl(email, width);\r
-               } else {\r
-                       url = ActivityUtils.getGravatarThumbnailUrl(email, width);\r
-               }\r
-               ExternalImage image = new ExternalImage("image", url);\r
-               if (cssClass != null) {\r
-                       WicketUtils.setCssClass(image, cssClass);\r
-               }\r
-               add(image);\r
-               WicketUtils.setHtmlTooltip(image, username);\r
-               setVisible(app().settings().getBoolean(Keys.web.allowGravatar, true));\r
-       }\r
+/*
+ * Copyright 2011 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.wicket.panels;
+
+import org.eclipse.jgit.lib.PersonIdent;
+
+import com.gitblit.Keys;
+import com.gitblit.models.UserModel;
+import com.gitblit.utils.ActivityUtils;
+import com.gitblit.wicket.ExternalImage;
+import com.gitblit.wicket.WicketUtils;
+
+/**
+ * Represents a Gravatar image.
+ *
+ * @author James Moger
+ *
+ */
+public class GravatarImage extends BasePanel {
+
+       private static final long serialVersionUID = 1L;
+
+       public GravatarImage(String id, PersonIdent person) {
+               this(id, person, 0);
+       }
+
+       public GravatarImage(String id, PersonIdent person, int width) {
+               this(id, person.getName(), person.getEmailAddress(), "gravatar", width, true);
+       }
+
+       public GravatarImage(String id, PersonIdent person, String cssClass, int width, boolean identicon) {
+               this(id, person.getName(), person.getEmailAddress(), cssClass, width, identicon);
+       }
+
+       public GravatarImage(String id, UserModel user, String cssClass, int width, boolean identicon) {
+               this(id, user.getDisplayName(), user.emailAddress, cssClass, width, identicon);
+       }
+
+       public GravatarImage(String id, String username, String emailaddress, String cssClass, int width, boolean identicon) {
+               super(id);
+
+               String email = emailaddress == null ? username.toLowerCase() : emailaddress.toLowerCase();
+               String url;
+               if (identicon) {
+                       url = ActivityUtils.getGravatarIdenticonUrl(email, width);
+               } else {
+                       url = ActivityUtils.getGravatarThumbnailUrl(email, width);
+               }
+               ExternalImage image = new ExternalImage("image", url);
+               if (cssClass != null) {
+                       WicketUtils.setCssClass(image, cssClass);
+               }
+               add(image);
+               WicketUtils.setHtmlTooltip(image, username);
+               setVisible(app().settings().getBoolean(Keys.web.allowGravatar, true));
+       }
+
+       public void setTooltip(String tooltip) {
+               WicketUtils.setHtmlTooltip(get("image"), tooltip);
+       }
 }
\ No newline at end of file
diff --git a/src/main/java/com/gitblit/wicket/panels/MarkdownTextArea.java b/src/main/java/com/gitblit/wicket/panels/MarkdownTextArea.java
new file mode 100644 (file)
index 0000000..fbce789
--- /dev/null
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2013 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.wicket.panels;
+
+import org.apache.wicket.ajax.AbstractAjaxTimerBehavior;
+import org.apache.wicket.ajax.AjaxRequestTarget;
+import org.apache.wicket.ajax.form.AjaxFormComponentUpdatingBehavior;
+import org.apache.wicket.markup.html.basic.Label;
+import org.apache.wicket.markup.html.form.TextArea;
+import org.apache.wicket.model.IModel;
+import org.apache.wicket.model.PropertyModel;
+import org.apache.wicket.util.time.Duration;
+
+import com.gitblit.utils.MarkdownUtils;
+import com.gitblit.wicket.GitBlitWebApp;
+
+public class MarkdownTextArea extends TextArea {
+
+       private static final long serialVersionUID = 1L;
+
+       protected String repositoryName;
+
+       protected String text = "";
+
+       public MarkdownTextArea(String id, final IModel<String> previewModel, final Label previewLabel) {
+               super(id);
+               this.repositoryName = repositoryName;
+               setModel(new PropertyModel(this, "text"));
+               add(new AjaxFormComponentUpdatingBehavior("onblur") {
+                       private static final long serialVersionUID = 1L;
+
+                       @Override
+                       protected void onUpdate(AjaxRequestTarget target) {
+                               renderPreview(previewModel);
+                               if (target != null) {
+                                       target.addComponent(previewLabel);
+                               }
+                       }
+               });
+               add(new AjaxFormComponentUpdatingBehavior("onchange") {
+                       private static final long serialVersionUID = 1L;
+
+                       @Override
+                       protected void onUpdate(AjaxRequestTarget target) {
+                               renderPreview(previewModel);
+                               if (target != null) {
+                                       target.addComponent(previewLabel);
+                               }
+                       }
+               });
+
+               add(new KeepAliveBehavior());
+               setOutputMarkupId(true);
+       }
+
+       protected void renderPreview(IModel<String> previewModel) {
+               if (text == null) {
+                       return;
+               }
+               String html = MarkdownUtils.transformGFM(GitBlitWebApp.get().settings(), text, repositoryName);
+               previewModel.setObject(html);
+       }
+
+       public String getText() {
+               return text;
+       }
+
+       public void setText(String text) {
+               this.text = text;
+       }
+
+       public void setRepository(String repositoryName) {
+               this.repositoryName = repositoryName;
+       }
+
+//     @Override
+//     protected void onBeforeRender() {
+//             super.onBeforeRender();
+//             add(new RichTextSetActiveTextFieldAttributeModifier(this.getMarkupId()));
+//     }
+//
+//     private class RichTextSetActiveTextFieldAttributeModifier extends AttributeModifier {
+//
+//             private static final long serialVersionUID = 1L;
+//
+//             public RichTextSetActiveTextFieldAttributeModifier(String markupId) {
+//                     super("onClick", true, new Model("richTextSetActiveTextField('" + markupId + "');"));
+//             }
+//     }
+
+       private class KeepAliveBehavior extends AbstractAjaxTimerBehavior {
+
+               private static final long serialVersionUID = 1L;
+
+               public KeepAliveBehavior() {
+                       super(Duration.minutes(5));
+               }
+
+               @Override
+               protected void onTimer(AjaxRequestTarget target) {
+                       // prevent wicket changing focus
+                       target.focusComponent(null);
+               }
+       }
+}
\ No newline at end of file
index 8df2849429cecd2a1b7b40c028e6342465202ca5..3a0b0b8cc484faaaeb78b7f92019c4cb53fd975a 100644 (file)
@@ -12,7 +12,7 @@
                <td class="icon hidden-phone"><i wicket:id="changeIcon"></i></td>\r
                <td style="padding-left: 7px;vertical-align:middle;">\r
                        <div>\r
-                               <span class="when" wicket:id="whenChanged"></span> <span wicket:id="refRewind" class="alert alert-error" style="padding: 1px 5px;font-size: 10px;font-weight: bold;margin-left: 10px;">[rewind]</span>\r
+                               <span class="when" wicket:id="whenChanged"></span> <span wicket:id="refRewind" class="aui-lozenge aui-lozenge-error">[rewind]</span>\r
                        </div>\r
                        <div style="font-weight:bold;"><span wicket:id="whoChanged">[change author]</span> <span wicket:id="whatChanged"></span> <span wicket:id="refChanged"></span> <span wicket:id="byAuthors"></span></div>\r
                </td>\r
@@ -26,7 +26,7 @@
                                                <td class="hidden-phone hidden-tablet" style="vertical-align:top;padding-left:7px;"><span wicket:id="commitAuthor"></span></td>\r
                                                <td style="vertical-align:top;"><span wicket:id="hashLink" style="padding-left: 5px;">[hash link]</span></td>\r
                                                <td style="vertical-align:top;padding-left:5px;"><img wicket:id="commitIcon" /></td>\r
-                                               <td style="vertical-align:top;">                                                        \r
+                                               <td style="vertical-align:top;">\r
                                                        <span wicket:id="commitShortMessage">[commit short message]</span>\r
                                                </td>\r
                                        </tr>\r
index c1db726ac8a578ba8d64c89a80ce8c0d937779bd..baefc6bdca23578061caa4fd7a17f58b538b1e9b 100644 (file)
-/*\r
- * Copyright 2013 gitblit.com.\r
- *\r
- * Licensed under the Apache License, Version 2.0 (the "License");\r
- * you may not use this file except in compliance with the License.\r
- * You may obtain a copy of the License at\r
- *\r
- *     http://www.apache.org/licenses/LICENSE-2.0\r
- *\r
- * Unless required by applicable law or agreed to in writing, software\r
- * distributed under the License is distributed on an "AS IS" BASIS,\r
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r
- * See the License for the specific language governing permissions and\r
- * limitations under the License.\r
- */\r
-package com.gitblit.wicket.panels;\r
-\r
-import java.text.DateFormat;\r
-import java.text.MessageFormat;\r
-import java.text.SimpleDateFormat;\r
-import java.util.ArrayList;\r
-import java.util.Calendar;\r
-import java.util.Date;\r
-import java.util.List;\r
-import java.util.TimeZone;\r
-\r
-import org.apache.wicket.markup.html.basic.Label;\r
-import org.apache.wicket.markup.repeater.Item;\r
-import org.apache.wicket.markup.repeater.data.DataView;\r
-import org.apache.wicket.markup.repeater.data.ListDataProvider;\r
-import org.apache.wicket.model.StringResourceModel;\r
-import org.eclipse.jgit.lib.Repository;\r
-import org.eclipse.jgit.transport.ReceiveCommand.Type;\r
-\r
-import com.gitblit.Constants;\r
-import com.gitblit.Keys;\r
-import com.gitblit.models.RefLogEntry;\r
-import com.gitblit.models.RepositoryCommit;\r
-import com.gitblit.models.RepositoryModel;\r
-import com.gitblit.models.UserModel;\r
-import com.gitblit.utils.RefLogUtils;\r
-import com.gitblit.utils.StringUtils;\r
-import com.gitblit.utils.TimeUtils;\r
-import com.gitblit.wicket.WicketUtils;\r
-import com.gitblit.wicket.pages.CommitPage;\r
-import com.gitblit.wicket.pages.ComparePage;\r
-import com.gitblit.wicket.pages.ReflogPage;\r
-import com.gitblit.wicket.pages.TagPage;\r
-import com.gitblit.wicket.pages.TreePage;\r
-import com.gitblit.wicket.pages.UserPage;\r
-\r
-public class ReflogPanel extends BasePanel {\r
-\r
-       private static final long serialVersionUID = 1L;\r
-\r
-       private final boolean hasChanges;\r
-\r
-       private boolean hasMore;\r
-\r
-       public ReflogPanel(String wicketId, final RepositoryModel model, Repository r, int limit, int pageOffset) {\r
-               super(wicketId);\r
-               boolean pageResults = limit <= 0;\r
-               int changesPerPage = app().settings().getInteger(Keys.web.reflogChangesPerPage, 10);\r
-               if (changesPerPage <= 1) {\r
-                       changesPerPage = 10;\r
-               }\r
-\r
-               List<RefLogEntry> changes;\r
-               if (pageResults) {\r
-                       changes = RefLogUtils.getLogByRef(model.name, r, pageOffset * changesPerPage, changesPerPage);\r
-               } else {\r
-                       changes = RefLogUtils.getLogByRef(model.name, r, limit);\r
-               }\r
-\r
-               // inaccurate way to determine if there are more commits.\r
-               // works unless commits.size() represents the exact end.\r
-               hasMore = changes.size() >= changesPerPage;\r
-               hasChanges = changes.size() > 0;\r
-\r
-               setup(changes);\r
-\r
-               // determine to show pager, more, or neither\r
-               if (limit <= 0) {\r
-                       // no display limit\r
-                       add(new Label("moreChanges").setVisible(false));\r
-               } else {\r
-                       if (pageResults) {\r
-                               // paging\r
-                               add(new Label("moreChanges").setVisible(false));\r
-                       } else {\r
-                               // more\r
-                               if (changes.size() == limit) {\r
-                                       // show more\r
-                                       add(new LinkPanel("moreChanges", "link", new StringResourceModel("gb.moreChanges",\r
-                                                       this, null), ReflogPage.class,\r
-                                                       WicketUtils.newRepositoryParameter(model.name)));\r
-                               } else {\r
-                                       // no more\r
-                                       add(new Label("moreChanges").setVisible(false));\r
-                               }\r
-                       }\r
-               }\r
-       }\r
-\r
-       public ReflogPanel(String wicketId, List<RefLogEntry> changes) {\r
-               super(wicketId);\r
-               hasChanges = changes.size() > 0;\r
-               setup(changes);\r
-               add(new Label("moreChanges").setVisible(false));\r
-       }\r
-\r
-       protected void setup(List<RefLogEntry> changes) {\r
-\r
-               ListDataProvider<RefLogEntry> dp = new ListDataProvider<RefLogEntry>(changes);\r
-               DataView<RefLogEntry> changeView = new DataView<RefLogEntry>("change", dp) {\r
-                       private static final long serialVersionUID = 1L;\r
-\r
-                       @Override\r
-                       public void populateItem(final Item<RefLogEntry> changeItem) {\r
-                               final RefLogEntry change = changeItem.getModelObject();\r
-\r
-                               String dateFormat = app().settings().getString(Keys.web.datetimestampLongFormat, "EEEE, MMMM d, yyyy HH:mm Z");\r
-                               TimeZone timezone = getTimeZone();\r
-                               DateFormat df = new SimpleDateFormat(dateFormat);\r
-                               df.setTimeZone(timezone);\r
-                               Calendar cal = Calendar.getInstance(timezone);\r
-\r
-                               String fullRefName = change.getChangedRefs().get(0);\r
-                               String shortRefName = fullRefName;\r
-                               boolean isTag = false;\r
-                               if (shortRefName.startsWith(Constants.R_HEADS)) {\r
-                                       shortRefName = shortRefName.substring(Constants.R_HEADS.length());\r
-                               } else if (shortRefName.startsWith(Constants.R_TAGS)) {\r
-                                       shortRefName = shortRefName.substring(Constants.R_TAGS.length());\r
-                                       isTag = true;\r
-                               }\r
-\r
-                               String fuzzydate;\r
-                               TimeUtils tu = getTimeUtils();\r
-                               Date changeDate = change.date;\r
-                               if (TimeUtils.isToday(changeDate, timezone)) {\r
-                                       fuzzydate = tu.today();\r
-                               } else if (TimeUtils.isYesterday(changeDate, timezone)) {\r
-                                       fuzzydate = tu.yesterday();\r
-                               } else {\r
-                                       // calculate a fuzzy time ago date\r
-                       cal.setTime(changeDate);\r
-                       cal.set(Calendar.HOUR_OF_DAY, 0);\r
-                       cal.set(Calendar.MINUTE, 0);\r
-                       cal.set(Calendar.SECOND, 0);\r
-                       cal.set(Calendar.MILLISECOND, 0);\r
-                       Date date = cal.getTime();\r
-                                       fuzzydate = getTimeUtils().timeAgo(date);\r
-                               }\r
-                               changeItem.add(new Label("whenChanged", fuzzydate + ", " + df.format(changeDate)));\r
-\r
-                               Label changeIcon = new Label("changeIcon");\r
-                               if (Type.DELETE.equals(change.getChangeType(fullRefName))) {\r
-                                       WicketUtils.setCssClass(changeIcon, "iconic-trash-stroke");\r
-                               } else if (isTag) {\r
-                                       WicketUtils.setCssClass(changeIcon, "iconic-tag");\r
-                               } else {\r
-                                       WicketUtils.setCssClass(changeIcon, "iconic-upload");\r
-                               }\r
-                               changeItem.add(changeIcon);\r
-\r
-                               if (change.user.username.equals(change.user.emailAddress) && change.user.emailAddress.indexOf('@') > -1) {\r
-                                       // username is an email address - 1.2.1 push log bug\r
-                                       changeItem.add(new Label("whoChanged", change.user.getDisplayName()));\r
-                               } else if (change.user.username.equals(UserModel.ANONYMOUS.username)) {\r
-                                       // anonymous change\r
-                                       changeItem.add(new Label("whoChanged", getString("gb.anonymousUser")));\r
-                               } else {\r
-                                       // link to user account page\r
-                                       changeItem.add(new LinkPanel("whoChanged", null, change.user.getDisplayName(),\r
-                                                       UserPage.class, WicketUtils.newUsernameParameter(change.user.username)));\r
-                               }\r
-\r
-                               boolean isDelete = false;\r
-                               boolean isRewind = false;\r
-                               String what;\r
-                               String by = null;\r
-                               switch(change.getChangeType(fullRefName)) {\r
-                               case CREATE:\r
-                                       if (isTag) {\r
-                                               // new tag\r
-                                               what = getString("gb.pushedNewTag");\r
-                                       } else {\r
-                                               // new branch\r
-                                               what = getString("gb.pushedNewBranch");\r
-                                       }\r
-                                       break;\r
-                               case DELETE:\r
-                                       isDelete = true;\r
-                                       if (isTag) {\r
-                                               what = getString("gb.deletedTag");\r
-                                       } else {\r
-                                               what = getString("gb.deletedBranch");\r
-                                       }\r
-                                       break;\r
-                               case UPDATE_NONFASTFORWARD:\r
-                                       isRewind = true;\r
-                               default:\r
-                                       what = MessageFormat.format(change.getCommitCount() > 1 ? getString("gb.pushedNCommitsTo") : getString("gb.pushedOneCommitTo") , change.getCommitCount());\r
-\r
-                                       if (change.getAuthorCount() == 1) {\r
-                                               by = MessageFormat.format(getString("gb.byOneAuthor"), change.getAuthorIdent().getName());\r
-                                       } else {\r
-                                               by = MessageFormat.format(getString("gb.byNAuthors"), change.getAuthorCount());\r
-                                       }\r
-                                       break;\r
-                               }\r
-                               changeItem.add(new Label("whatChanged", what));\r
-                               changeItem.add(new Label("byAuthors", by).setVisible(!StringUtils.isEmpty(by)));\r
-\r
-                               changeItem.add(new Label("refRewind", getString("gb.rewind")).setVisible(isRewind));\r
-\r
-                               if (isDelete) {\r
-                                       // can't link to deleted ref\r
-                                       changeItem.add(new Label("refChanged", shortRefName));\r
-                               } else if (isTag) {\r
-                                       // link to tag\r
-                                       changeItem.add(new LinkPanel("refChanged", null, shortRefName,\r
-                                                       TagPage.class, WicketUtils.newObjectParameter(change.repository, fullRefName)));\r
-                               } else {\r
-                                       // link to tree\r
-                                       changeItem.add(new LinkPanel("refChanged", null, shortRefName,\r
-                                               TreePage.class, WicketUtils.newObjectParameter(change.repository, fullRefName)));\r
-                               }\r
-\r
-                               int maxCommitCount = 5;\r
-                               List<RepositoryCommit> commits = change.getCommits();\r
-                               if (commits.size() > maxCommitCount) {\r
-                                       commits = new ArrayList<RepositoryCommit>(commits.subList(0,  maxCommitCount));\r
-                               }\r
-\r
-                               // compare link\r
-                               String compareLinkText = null;\r
-                               if ((change.getCommitCount() <= maxCommitCount) && (change.getCommitCount() > 1)) {\r
-                                       compareLinkText = MessageFormat.format(getString("gb.viewComparison"), commits.size());\r
-                               } else if (change.getCommitCount() > maxCommitCount) {\r
-                                       int diff = change.getCommitCount() - maxCommitCount;\r
-                                       compareLinkText = MessageFormat.format(diff > 1 ? getString("gb.nMoreCommits") : getString("gb.oneMoreCommit"), diff);\r
-                               }\r
-                               if (StringUtils.isEmpty(compareLinkText)) {\r
-                                       changeItem.add(new Label("compareLink").setVisible(false));\r
-                               } else {\r
-                                       String endRangeId = change.getNewId(fullRefName);\r
-                                       String startRangeId = change.getOldId(fullRefName);\r
-                                       changeItem.add(new LinkPanel("compareLink", null, compareLinkText, ComparePage.class, WicketUtils.newRangeParameter(change.repository, startRangeId, endRangeId)));\r
-                               }\r
-\r
-                               ListDataProvider<RepositoryCommit> cdp = new ListDataProvider<RepositoryCommit>(commits);\r
-                               DataView<RepositoryCommit> commitsView = new DataView<RepositoryCommit>("commit", cdp) {\r
-                                       private static final long serialVersionUID = 1L;\r
-\r
-                                       @Override\r
-                                       public void populateItem(final Item<RepositoryCommit> commitItem) {\r
-                                               final RepositoryCommit commit = commitItem.getModelObject();\r
-\r
-                                               // author gravatar\r
-                                               commitItem.add(new GravatarImage("commitAuthor", commit.getAuthorIdent(), null, 16, false));\r
-\r
-                                               // merge icon\r
-                                               if (commit.getParentCount() > 1) {\r
-                                                       commitItem.add(WicketUtils.newImage("commitIcon", "commit_merge_16x16.png"));\r
-                                               } else {\r
-                                                       commitItem.add(WicketUtils.newBlankImage("commitIcon"));\r
-                                               }\r
-\r
-                                               // short message\r
-                                               String shortMessage = commit.getShortMessage();\r
-                                               String trimmedMessage = shortMessage;\r
-                                               if (commit.getRefs() != null && commit.getRefs().size() > 0) {\r
-                                                       trimmedMessage = StringUtils.trimString(shortMessage, Constants.LEN_SHORTLOG_REFS);\r
-                                               } else {\r
-                                                       trimmedMessage = StringUtils.trimString(shortMessage, Constants.LEN_SHORTLOG);\r
-                                               }\r
-                                               LinkPanel shortlog = new LinkPanel("commitShortMessage", "list",\r
-                                                               trimmedMessage, CommitPage.class, WicketUtils.newObjectParameter(\r
-                                                                               change.repository, commit.getName()));\r
-                                               if (!shortMessage.equals(trimmedMessage)) {\r
-                                                       WicketUtils.setHtmlTooltip(shortlog, shortMessage);\r
-                                               }\r
-                                               commitItem.add(shortlog);\r
-\r
-                                               // commit hash link\r
-                                               int hashLen = app().settings().getInteger(Keys.web.shortCommitIdLength, 6);\r
-                                               LinkPanel commitHash = new LinkPanel("hashLink", null, commit.getName().substring(0, hashLen),\r
-                                                               CommitPage.class, WicketUtils.newObjectParameter(\r
-                                                                               change.repository, commit.getName()));\r
-                                               WicketUtils.setCssClass(commitHash, "shortsha1");\r
-                                               WicketUtils.setHtmlTooltip(commitHash, commit.getName());\r
-                                               commitItem.add(commitHash);\r
-                                       }\r
-                               };\r
-\r
-                               changeItem.add(commitsView);\r
-                       }\r
-               };\r
-\r
-               add(changeView);\r
-       }\r
-\r
-       public boolean hasMore() {\r
-               return hasMore;\r
-       }\r
-\r
-       public boolean hideIfEmpty() {\r
-               setVisible(hasChanges);\r
-               return hasChanges;\r
-       }\r
-}\r
+/*
+ * Copyright 2013 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.wicket.panels;
+
+import java.text.DateFormat;
+import java.text.MessageFormat;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+import java.util.TimeZone;
+
+import org.apache.wicket.markup.html.basic.Label;
+import org.apache.wicket.markup.repeater.Item;
+import org.apache.wicket.markup.repeater.data.DataView;
+import org.apache.wicket.markup.repeater.data.ListDataProvider;
+import org.apache.wicket.model.StringResourceModel;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand.Type;
+
+import com.gitblit.Constants;
+import com.gitblit.Keys;
+import com.gitblit.models.RefLogEntry;
+import com.gitblit.models.RepositoryCommit;
+import com.gitblit.models.RepositoryModel;
+import com.gitblit.models.UserModel;
+import com.gitblit.utils.RefLogUtils;
+import com.gitblit.utils.StringUtils;
+import com.gitblit.utils.TimeUtils;
+import com.gitblit.wicket.WicketUtils;
+import com.gitblit.wicket.pages.CommitPage;
+import com.gitblit.wicket.pages.ComparePage;
+import com.gitblit.wicket.pages.ReflogPage;
+import com.gitblit.wicket.pages.TagPage;
+import com.gitblit.wicket.pages.TicketsPage;
+import com.gitblit.wicket.pages.TreePage;
+import com.gitblit.wicket.pages.UserPage;
+
+public class ReflogPanel extends BasePanel {
+
+       private static final long serialVersionUID = 1L;
+
+       private final boolean hasChanges;
+
+       private boolean hasMore;
+
+       public ReflogPanel(String wicketId, final RepositoryModel model, Repository r, int limit, int pageOffset) {
+               super(wicketId);
+               boolean pageResults = limit <= 0;
+               int changesPerPage = app().settings().getInteger(Keys.web.reflogChangesPerPage, 10);
+               if (changesPerPage <= 1) {
+                       changesPerPage = 10;
+               }
+
+               List<RefLogEntry> changes;
+               if (pageResults) {
+                       changes = RefLogUtils.getLogByRef(model.name, r, pageOffset * changesPerPage, changesPerPage);
+               } else {
+                       changes = RefLogUtils.getLogByRef(model.name, r, limit);
+               }
+
+               // inaccurate way to determine if there are more commits.
+               // works unless commits.size() represents the exact end.
+               hasMore = changes.size() >= changesPerPage;
+               hasChanges = changes.size() > 0;
+
+               setup(changes);
+
+               // determine to show pager, more, or neither
+               if (limit <= 0) {
+                       // no display limit
+                       add(new Label("moreChanges").setVisible(false));
+               } else {
+                       if (pageResults) {
+                               // paging
+                               add(new Label("moreChanges").setVisible(false));
+                       } else {
+                               // more
+                               if (changes.size() == limit) {
+                                       // show more
+                                       add(new LinkPanel("moreChanges", "link", new StringResourceModel("gb.moreChanges",
+                                                       this, null), ReflogPage.class,
+                                                       WicketUtils.newRepositoryParameter(model.name)));
+                               } else {
+                                       // no more
+                                       add(new Label("moreChanges").setVisible(false));
+                               }
+                       }
+               }
+       }
+
+       public ReflogPanel(String wicketId, List<RefLogEntry> changes) {
+               super(wicketId);
+               hasChanges = changes.size() > 0;
+               setup(changes);
+               add(new Label("moreChanges").setVisible(false));
+       }
+
+       protected void setup(List<RefLogEntry> changes) {
+
+               ListDataProvider<RefLogEntry> dp = new ListDataProvider<RefLogEntry>(changes);
+               DataView<RefLogEntry> changeView = new DataView<RefLogEntry>("change", dp) {
+                       private static final long serialVersionUID = 1L;
+
+                       @Override
+                       public void populateItem(final Item<RefLogEntry> changeItem) {
+                               final RefLogEntry change = changeItem.getModelObject();
+
+                               String dateFormat = app().settings().getString(Keys.web.datetimestampLongFormat, "EEEE, MMMM d, yyyy HH:mm Z");
+                               TimeZone timezone = getTimeZone();
+                               DateFormat df = new SimpleDateFormat(dateFormat);
+                               df.setTimeZone(timezone);
+                               Calendar cal = Calendar.getInstance(timezone);
+
+                               String fullRefName = change.getChangedRefs().get(0);
+                               String shortRefName = fullRefName;
+                               String ticketId = null;
+                               boolean isTag = false;
+                               boolean isTicket = false;
+                               if (shortRefName.startsWith(Constants.R_TICKET)) {
+                                       ticketId = fullRefName.substring(Constants.R_TICKET.length());
+                                       shortRefName = MessageFormat.format(getString("gb.ticketN"), ticketId);
+                                       isTicket = true;
+                               } else if (shortRefName.startsWith(Constants.R_HEADS)) {
+                                       shortRefName = shortRefName.substring(Constants.R_HEADS.length());
+                               } else if (shortRefName.startsWith(Constants.R_TAGS)) {
+                                       shortRefName = shortRefName.substring(Constants.R_TAGS.length());
+                                       isTag = true;
+                               }
+
+                               String fuzzydate;
+                               TimeUtils tu = getTimeUtils();
+                               Date changeDate = change.date;
+                               if (TimeUtils.isToday(changeDate, timezone)) {
+                                       fuzzydate = tu.today();
+                               } else if (TimeUtils.isYesterday(changeDate, timezone)) {
+                                       fuzzydate = tu.yesterday();
+                               } else {
+                                       // calculate a fuzzy time ago date
+                       cal.setTime(changeDate);
+                       cal.set(Calendar.HOUR_OF_DAY, 0);
+                       cal.set(Calendar.MINUTE, 0);
+                       cal.set(Calendar.SECOND, 0);
+                       cal.set(Calendar.MILLISECOND, 0);
+                       Date date = cal.getTime();
+                                       fuzzydate = getTimeUtils().timeAgo(date);
+                               }
+                               changeItem.add(new Label("whenChanged", fuzzydate + ", " + df.format(changeDate)));
+
+                               Label changeIcon = new Label("changeIcon");
+                               if (Type.DELETE.equals(change.getChangeType(fullRefName))) {
+                                       WicketUtils.setCssClass(changeIcon, "iconic-trash-stroke");
+                               } else if (isTag) {
+                                       WicketUtils.setCssClass(changeIcon, "iconic-tag");
+                               } else if (isTicket) {
+                                       WicketUtils.setCssClass(changeIcon, "fa fa-ticket");
+                               } else {
+                                       WicketUtils.setCssClass(changeIcon, "iconic-upload");
+                               }
+                               changeItem.add(changeIcon);
+
+                               if (change.user.username.equals(change.user.emailAddress) && change.user.emailAddress.indexOf('@') > -1) {
+                                       // username is an email address - 1.2.1 push log bug
+                                       changeItem.add(new Label("whoChanged", change.user.getDisplayName()));
+                               } else if (change.user.username.equals(UserModel.ANONYMOUS.username)) {
+                                       // anonymous change
+                                       changeItem.add(new Label("whoChanged", getString("gb.anonymousUser")));
+                               } else {
+                                       // link to user account page
+                                       changeItem.add(new LinkPanel("whoChanged", null, change.user.getDisplayName(),
+                                                       UserPage.class, WicketUtils.newUsernameParameter(change.user.username)));
+                               }
+
+                               boolean isDelete = false;
+                               boolean isRewind = false;
+                               String what;
+                               String by = null;
+                               switch(change.getChangeType(fullRefName)) {
+                               case CREATE:
+                                       if (isTag) {
+                                               // new tag
+                                               what = getString("gb.pushedNewTag");
+                                       } else {
+                                               // new branch
+                                               what = getString("gb.pushedNewBranch");
+                                       }
+                                       break;
+                               case DELETE:
+                                       isDelete = true;
+                                       if (isTag) {
+                                               what = getString("gb.deletedTag");
+                                       } else {
+                                               what = getString("gb.deletedBranch");
+                                       }
+                                       break;
+                               case UPDATE_NONFASTFORWARD:
+                                       isRewind = true;
+                               default:
+                                       what = MessageFormat.format(change.getCommitCount() > 1 ? getString("gb.pushedNCommitsTo") : getString("gb.pushedOneCommitTo"), change.getCommitCount());
+
+                                       if (change.getAuthorCount() == 1) {
+                                               by = MessageFormat.format(getString("gb.byOneAuthor"), change.getAuthorIdent().getName());
+                                       } else {
+                                               by = MessageFormat.format(getString("gb.byNAuthors"), change.getAuthorCount());
+                                       }
+                                       break;
+                               }
+                               changeItem.add(new Label("whatChanged", what));
+                               changeItem.add(new Label("byAuthors", by).setVisible(!StringUtils.isEmpty(by)));
+                               changeItem.add(new Label("refRewind", getString("gb.rewind")).setVisible(isRewind));
+
+                               if (isDelete) {
+                                       // can't link to deleted ref
+                                       changeItem.add(new Label("refChanged", shortRefName));
+                               } else if (isTag) {
+                                       // link to tag
+                                       changeItem.add(new LinkPanel("refChanged", null, shortRefName,
+                                                       TagPage.class, WicketUtils.newObjectParameter(change.repository, fullRefName)));
+                               } else if (isTicket) {
+                                       // link to ticket
+                                       changeItem.add(new LinkPanel("refChanged", null, shortRefName,
+                                                       TicketsPage.class, WicketUtils.newObjectParameter(change.repository, ticketId)));
+                               } else {
+                                       // link to tree
+                                       changeItem.add(new LinkPanel("refChanged", null, shortRefName,
+                                               TreePage.class, WicketUtils.newObjectParameter(change.repository, fullRefName)));
+                               }
+
+                               int maxCommitCount = 5;
+                               List<RepositoryCommit> commits = change.getCommits();
+                               if (commits.size() > maxCommitCount) {
+                                       commits = new ArrayList<RepositoryCommit>(commits.subList(0,  maxCommitCount));
+                               }
+
+                               // compare link
+                               String compareLinkText = null;
+                               if ((change.getCommitCount() <= maxCommitCount) && (change.getCommitCount() > 1)) {
+                                       compareLinkText = MessageFormat.format(getString("gb.viewComparison"), commits.size());
+                               } else if (change.getCommitCount() > maxCommitCount) {
+                                       int diff = change.getCommitCount() - maxCommitCount;
+                                       compareLinkText = MessageFormat.format(diff > 1 ? getString("gb.nMoreCommits") : getString("gb.oneMoreCommit"), diff);
+                               }
+                               if (StringUtils.isEmpty(compareLinkText)) {
+                                       changeItem.add(new Label("compareLink").setVisible(false));
+                               } else {
+                                       String endRangeId = change.getNewId(fullRefName);
+                                       String startRangeId = change.getOldId(fullRefName);
+                                       changeItem.add(new LinkPanel("compareLink", null, compareLinkText, ComparePage.class, WicketUtils.newRangeParameter(change.repository, startRangeId, endRangeId)));
+                               }
+
+                               ListDataProvider<RepositoryCommit> cdp = new ListDataProvider<RepositoryCommit>(commits);
+                               DataView<RepositoryCommit> commitsView = new DataView<RepositoryCommit>("commit", cdp) {
+                                       private static final long serialVersionUID = 1L;
+
+                                       @Override
+                                       public void populateItem(final Item<RepositoryCommit> commitItem) {
+                                               final RepositoryCommit commit = commitItem.getModelObject();
+
+                                               // author gravatar
+                                               commitItem.add(new GravatarImage("commitAuthor", commit.getAuthorIdent(), null, 16, false));
+
+                                               // merge icon
+                                               if (commit.getParentCount() > 1) {
+                                                       commitItem.add(WicketUtils.newImage("commitIcon", "commit_merge_16x16.png"));
+                                               } else {
+                                                       commitItem.add(WicketUtils.newBlankImage("commitIcon"));
+                                               }
+
+                                               // short message
+                                               String shortMessage = commit.getShortMessage();
+                                               String trimmedMessage = shortMessage;
+                                               if (commit.getRefs() != null && commit.getRefs().size() > 0) {
+                                                       trimmedMessage = StringUtils.trimString(shortMessage, Constants.LEN_SHORTLOG_REFS);
+                                               } else {
+                                                       trimmedMessage = StringUtils.trimString(shortMessage, Constants.LEN_SHORTLOG);
+                                               }
+                                               LinkPanel shortlog = new LinkPanel("commitShortMessage", "list",
+                                                               trimmedMessage, CommitPage.class, WicketUtils.newObjectParameter(
+                                                                               change.repository, commit.getName()));
+                                               if (!shortMessage.equals(trimmedMessage)) {
+                                                       WicketUtils.setHtmlTooltip(shortlog, shortMessage);
+                                               }
+                                               commitItem.add(shortlog);
+
+                                               // commit hash link
+                                               int hashLen = app().settings().getInteger(Keys.web.shortCommitIdLength, 6);
+                                               LinkPanel commitHash = new LinkPanel("hashLink", null, commit.getName().substring(0, hashLen),
+                                                               CommitPage.class, WicketUtils.newObjectParameter(
+                                                                               change.repository, commit.getName()));
+                                               WicketUtils.setCssClass(commitHash, "shortsha1");
+                                               WicketUtils.setHtmlTooltip(commitHash, commit.getName());
+                                               commitItem.add(commitHash);
+                                       }
+                               };
+
+                               changeItem.add(commitsView);
+                       }
+               };
+
+               add(changeView);
+       }
+
+       public boolean hasMore() {
+               return hasMore;
+       }
+
+       public boolean hideIfEmpty() {
+               setVisible(hasChanges);
+               return hasChanges;
+       }
+}
index 7a16f4a28bb25bdd4e04826cbdfefdebe00d2c73..6e9e866efa185b4dbbfef8216d5a6c2c36e710e8 100644 (file)
@@ -25,7 +25,6 @@ import java.util.Map;
 import org.apache.wicket.Component;\r
 import org.apache.wicket.markup.html.WebPage;\r
 import org.apache.wicket.markup.html.basic.Label;\r
-import org.apache.wicket.markup.html.panel.Panel;\r
 import org.apache.wicket.markup.repeater.Item;\r
 import org.apache.wicket.markup.repeater.data.DataView;\r
 import org.apache.wicket.markup.repeater.data.ListDataProvider;\r
@@ -34,13 +33,15 @@ import org.eclipse.jgit.revwalk.RevCommit;
 \r
 import com.gitblit.Constants;\r
 import com.gitblit.models.RefModel;\r
+import com.gitblit.models.RepositoryModel;\r
 import com.gitblit.utils.StringUtils;\r
 import com.gitblit.wicket.WicketUtils;\r
 import com.gitblit.wicket.pages.CommitPage;\r
 import com.gitblit.wicket.pages.LogPage;\r
 import com.gitblit.wicket.pages.TagPage;\r
+import com.gitblit.wicket.pages.TicketsPage;\r
 \r
-public class RefsPanel extends Panel {\r
+public class RefsPanel extends BasePanel {\r
 \r
        private static final long serialVersionUID = 1L;\r
 \r
@@ -88,6 +89,8 @@ public class RefsPanel extends Panel {
                        }\r
                }\r
                final boolean shouldBreak = remoteCount < refs.size();\r
+               RepositoryModel repository = app().repositories().getRepositoryModel(repositoryName);\r
+               final boolean hasTickets = app().tickets().hasTickets(repository);\r
 \r
                ListDataProvider<RefModel> refsDp = new ListDataProvider<RefModel>(refs);\r
                DataView<RefModel> refsView = new DataView<RefModel>("ref", refsDp) {\r
@@ -103,7 +106,13 @@ public class RefsPanel extends Panel {
                                Class<? extends WebPage> linkClass = CommitPage.class;\r
                                String cssClass = "";\r
                                String tooltip = "";\r
-                               if (name.startsWith(Constants.R_HEADS)) {\r
+                               if (name.startsWith(Constants.R_TICKET)) {\r
+                                       // Gitblit ticket ref\r
+                                       objectid = name.substring(Constants.R_TICKET.length());\r
+                                       name = name.substring(Constants.R_HEADS.length());\r
+                                       linkClass = TicketsPage.class;\r
+                                       cssClass = "localBranch";\r
+                               } else if (name.startsWith(Constants.R_HEADS)) {\r
                                        // local branch\r
                                        linkClass = LogPage.class;\r
                                        name = name.substring(Constants.R_HEADS.length());\r
@@ -113,13 +122,23 @@ public class RefsPanel extends Panel {
                                        linkClass = LogPage.class;\r
                                        cssClass = "headRef";\r
                                } else if (name.startsWith(Constants.R_CHANGES)) {\r
-                                       // Gerrit change ref\r
+                                       // Gitblit change ref\r
                                        name = name.substring(Constants.R_CHANGES.length());\r
                                        // strip leading nn/ from nn/#####nn/ps = #####nn-ps\r
                                        name = name.substring(name.indexOf('/') + 1).replace('/', '-');\r
                                        String [] values = name.split("-");\r
+                                       // Gerrit change\r
                                        tooltip = MessageFormat.format(getString("gb.reviewPatchset"), values[0], values[1]);\r
                                        cssClass = "otherRef";\r
+                               } else if (name.startsWith(Constants.R_TICKETS_PATCHSETS)) {\r
+                                       // Gitblit patchset ref\r
+                                       name = name.substring(Constants.R_TICKETS_PATCHSETS.length());\r
+                                       // strip leading nn/ from nn/#####nn/ps = #####nn-ps\r
+                                       name = name.substring(name.indexOf('/') + 1).replace('/', '-');\r
+                                       String [] values = name.split("-");\r
+                                       tooltip = MessageFormat.format(getString("gb.ticketPatchset"), values[0], values[1]);\r
+                                       linkClass = LogPage.class;\r
+                                       cssClass = "otherRef";\r
                                } else if (name.startsWith(Constants.R_PULL)) {\r
                                        // Pull Request ref\r
                                        String num = name.substring(Constants.R_PULL.length());\r
diff --git a/src/main/java/pt.cmd b/src/main/java/pt.cmd
new file mode 100644 (file)
index 0000000..cec7e5f
--- /dev/null
@@ -0,0 +1 @@
+@python %~dp0pt.py %1 %2 %3 %4 %5 %6 %7 %8 %9
diff --git a/src/main/java/pt.py b/src/main/java/pt.py
new file mode 100644 (file)
index 0000000..f1fe27f
--- /dev/null
@@ -0,0 +1,701 @@
+#!/usr/bin/env python3
+#
+# Barnum, a Patchset Tool (pt)
+#
+# This Git wrapper script is designed to reduce the ceremony of working with Gitblit patchsets.
+#
+# Copyright 2014 gitblit.com.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+#
+# Usage:
+#
+#    pt fetch <id> [-p,--patchset <n>]
+#    pt checkout <id> [-p,--patchset <n>] [-f,--force]
+#    pt pull <id> [-p,--patchset <n>]
+#    pt push [<id>] [-i,--ignore] [-f,--force] [-m,--milestone <milestone>] [-t,--topic <topic>] [-cc <user> <user>]
+#    pt start <topic> | <id>
+#    pt propose [new | <branch> | <id>] [-i,--ignore] [-m,--milestone <milestone>] [-t,--topic <topic>] [-cc <user> <user>]
+#    pt cleanup [<id>]
+#
+
+__author__ = 'James Moger'
+__version__ = '1.0.5'
+
+import subprocess
+import argparse
+import errno
+import sys
+
+
+def fetch(args):
+    """
+    fetch(args)
+
+    Fetches the specified patchset for the ticket from the specified remote.
+    """
+
+    __resolve_remote(args)
+
+    # fetch the patchset from the remote repository
+
+    if args.patchset is None:
+        # fetch all current ticket patchsets
+        print("Fetching ticket patchsets from the '{}' repository".format(args.remote))
+        if args.quiet:
+            __call(['git', 'fetch', args.remote, '--quiet'])
+        else:
+            __call(['git', 'fetch', args.remote])
+    else:
+        # fetch specific patchset
+        __resolve_patchset(args)
+        print("Fetching ticket {} patchset {} from the '{}' repository".format(args.id, args.patchset, args.remote))
+        patchset_ref = 'refs/tickets/{:02d}/{:d}/{:d}'.format(args.id % 100, args.id, args.patchset)
+        if args.quiet:
+            __call(['git', 'fetch', args.remote, patchset_ref, '--quiet'])
+        else:
+            __call(['git', 'fetch', args.remote, patchset_ref])
+
+    return
+
+
+def checkout(args):
+    """
+    checkout(args)
+
+    Checkout the patchset on a named branch.
+    """
+
+    __resolve_uncommitted_changes_checkout(args)
+    fetch(args)
+
+    # collect local branch names
+    branches = []
+    for branch in __call(['git', 'branch']):
+        if branch[0] == '*':
+            branches.append(branch[1:].strip())
+        else:
+            branches.append(branch.strip())
+
+    if args.patchset is None or args.patchset is 0:
+        branch = 'ticket/{:d}'.format(args.id)
+        illegals = set(branches) & {'ticket'}
+    else:
+        branch = 'patchset/{:d}/{:d}'.format(args.id, args.patchset)
+        illegals = set(branches) & {'patchset', 'patchset/{:d}'.format(args.id)}
+
+    # ensure there are no local branch names that will interfere with branch creation
+    if len(illegals) > 0:
+        print('')
+        print('Sorry, can not complete the checkout for ticket {}.'.format(args.id))
+        print("The following branches are blocking '{}' branch creation:".format(branch))
+        for illegal in illegals:
+            print('  ' + illegal)
+        exit(errno.EINVAL)
+
+    if args.patchset is None or args.patchset is 0:
+        # checkout the current ticket patchset
+        if args.force:
+            __call(['git', 'checkout', '-B', branch, '{}/{}'.format(args.remote, branch)])
+        else:
+            __call(['git', 'checkout', branch])
+    else:
+        # checkout a specific patchset
+        __checkout(args.remote, args.id, args.patchset, branch, args.force)
+
+    return
+
+
+def pull(args):
+    """
+    pull(args)
+
+    Pull (fetch & merge) a ticket patchset into the current branch.
+    """
+
+    __resolve_uncommitted_changes_checkout(args)
+    __resolve_remote(args)
+
+    # reset the checkout before pulling
+    __call(['git', 'reset', '--hard'])
+
+    # pull the patchset from the remote repository
+    if args.patchset is None or args.patchset is 0:
+        print("Pulling ticket {} from the '{}' repository".format(args.id, args.remote))
+        patchset_ref = 'ticket/{:d}'.format(args.id)
+    else:
+        __resolve_patchset(args)
+        print("Pulling ticket {} patchset {} from the '{}' repository".format(args.id, args.patchset, args.remote))
+        patchset_ref = 'refs/tickets/{:02d}/{:d}/{:d}'.format(args.id % 100, args.id, args.patchset)
+
+    if args.squash:
+        __call(['git', 'pull', '--squash', '--no-log', '--no-rebase', args.remote, patchset_ref], echo=True)
+    else:
+        __call(['git', 'pull', '--commit', '--no-ff', '--no-log', '--no-rebase', args.remote, patchset_ref], echo=True)
+
+    return
+
+
+def push(args):
+    """
+    push(args)
+
+    Push your patchset update or a patchset rewrite.
+    """
+
+    if args.id is None:
+        # try to determine ticket and patchset from current branch name
+        for line in __call(['git', 'status', '-b', '-s']):
+            if line[0:2] == '##':
+                branch = line[2:].strip()
+                segments = branch.split('/')
+                if len(segments) >= 2:
+                    if segments[0] == 'ticket' or segments[0] == 'patchset':
+                        if '...' in segments[1]:
+                            args.id = int(segments[1][:segments[1].index('...')])
+                        else:
+                            args.id = int(segments[1])
+                        args.patchset = None
+
+    if args.id is None:
+        print('Please specify a ticket id for the push command.')
+        exit(errno.EINVAL)
+
+    __resolve_uncommitted_changes_push(args)
+    __resolve_remote(args)
+
+    if args.force:
+       # rewrite a patchset for an existing ticket
+        push_ref = 'refs/for/' + str(args.id)
+    else:
+        # fast-forward update to an existing patchset
+        push_ref = 'refs/heads/ticket/{:d}'.format(args.id)
+
+    ref_params = __get_pushref_params(args)
+    ref_spec = 'HEAD:' + push_ref + ref_params
+
+    print("Pushing your patchset to the '{}' repository".format(args.remote))
+    __call(['git', 'push', args.remote, ref_spec], echo=True)
+
+    if args.force and args.patchset is not None and args.patchset is not 0:
+        # if we had to force the push then there is a new patchset
+        # revision on the server so checkout out the new patchset
+        args.patchset = None
+        args.force = False
+        args.quiet = True
+        checkout(args)
+
+    return
+
+
+def start(args):
+    """
+    start(args)
+
+    Start development of a topic on a new branch.
+    """
+
+    # collect local branch names
+    branches = []
+    for branch in __call(['git', 'branch']):
+        if branch[0] == '*':
+            branches.append(branch[1:].strip())
+        else:
+            branches.append(branch.strip())
+
+    branch = 'topic/' + args.topic
+    illegals = set(branches) & {'topic', branch}
+
+    # ensure there are no local branch names that will interfere with branch creation
+    if len(illegals) > 0:
+        print('Sorry, can not complete the creation of the topic branch.')
+        print("The following branches are blocking '{}' branch creation:".format(branch))
+        for illegal in illegals:
+            print('  ' + illegal)
+        exit(errno.EINVAL)
+
+    __call(['git', 'checkout', '-b', branch])
+
+    return
+
+
+def propose(args):
+    """
+    propose_patchset(args)
+
+    Push a patchset to create a new proposal ticket or to attach a proposal patchset to an existing ticket.
+    """
+
+    __resolve_uncommitted_changes_push(args)
+    __resolve_remote(args)
+
+    curr_branch = None
+    push_ref = None
+    if args.target is None:
+        # see if the topic is a ticket id
+        # else default to new
+        for branch in __call(['git', 'branch']):
+            if branch[0] == '*':
+                curr_branch = branch[1:].strip()
+                if curr_branch.startswith('topic/'):
+                    topic = curr_branch[6:].strip()
+                    try:
+                        int(topic)
+                        push_ref = topic
+                    except ValueError:
+                        pass
+        if push_ref is None:
+            push_ref = 'new'
+    else:
+        push_ref = args.target
+
+    try:
+        # check for current patchset and current branch
+        args.id = int(push_ref)
+        args.patchset = __get_current_patchset(args.remote, args.id)
+        if args.patchset > 0:
+            print('You can not propose a patchset for ticket {} because it already has one.'.format(args.id))
+
+            # check current branch for accidental propose instead of push
+            for line in __call(['git', 'status', '-b', '-s']):
+                if line[0:2] == '##':
+                    branch = line[2:].strip()
+                    segments = branch.split('/')
+                    if len(segments) >= 2:
+                        if segments[0] == 'ticket':
+                            if '...' in segments[1]:
+                                args.id = int(segments[1][:segments[1].index('...')])
+                            else:
+                                args.id = int(segments[1])
+                            args.patchset = None
+                            print("You are on the '{}' branch, perhaps you meant to push instead?".format(branch))
+                        elif segments[0] == 'patchset':
+                            args.id = int(segments[1])
+                            args.patchset = int(segments[2])
+                            print("You are on the '{}' branch, perhaps you meant to push instead?".format(branch))
+            exit(errno.EINVAL)
+    except ValueError:
+        pass
+
+    ref_params = __get_pushref_params(args)
+    ref_spec = 'HEAD:refs/for/{}{}'.format(push_ref, ref_params)
+
+    print("Pushing your proposal to the '{}' repository".format(args.remote))
+    for line in __call(['git', 'push', args.remote, ref_spec, '-q'], echo=True, err=subprocess.STDOUT):
+        fields = line.split(':')
+        if fields[0] == 'remote' and fields[1].strip().startswith('--> #'):
+            # set the upstream branch configuration
+            args.id = int(fields[1].strip()[len('--> #'):])
+            __call(['git', 'fetch', args.remote])
+            __call(['git', 'branch', '--set-upstream-to={}/ticket/{:d}'.format(args.remote, args.id)])
+            break
+
+    return
+
+
+def cleanup(args):
+    """
+    cleanup(args)
+
+    Removes local branches for the ticket.
+    """
+
+    if args.id is None:
+        branches = __call(['git', 'branch', '--list', 'ticket/*'])
+        branches += __call(['git', 'branch', '--list', 'patchset/*'])
+    else:
+        branches = __call(['git', 'branch', '--list', 'ticket/{:d}'.format(args.id)])
+        branches += __call(['git', 'branch', '--list', 'patchset/{:d}/*'.format(args.id)])
+
+    if len(branches) == 0:
+        print("No local branches found for ticket {}, cleanup skipped.".format(args.id))
+        return
+
+    if not args.force:
+        print('Cleanup would remove the following local branches for ticket {}.'.format(args.id))
+        for branch in branches:
+            if branch[0] == '*':
+                print('  ' + branch[1:].strip() + ' (skip)')
+            else:
+                print('  ' + branch)
+        print("To discard these local branches, repeat this command with '--force'.")
+        exit(errno.EINVAL)
+
+    for branch in branches:
+        if branch[0] == '*':
+            print('Skipped {} because it is the current branch.'.format(branch[1:].strip()))
+            continue
+        __call(['git', 'branch', '-D', branch.strip()], echo=True)
+
+    return
+
+
+def __resolve_uncommitted_changes_checkout(args):
+    """
+    __resolve_uncommitted_changes_checkout(args)
+
+    Ensures the current checkout has no uncommitted changes that would be discarded by a checkout or pull.
+    """
+
+    status = __call(['git', 'status', '--porcelain'])
+    for line in status:
+        if not args.force and line[0] != '?':
+            print('Your local changes to the following files would be overwritten by {}:'.format(args.command))
+            print('')
+            for state in status:
+                print(state)
+            print('')
+            print("To discard your local changes, repeat the {} with '--force'.".format(args.command))
+            print('NOTE: forcing a {} will HARD RESET your working directory!'.format(args.command))
+            exit(errno.EINVAL)
+
+
+def __resolve_uncommitted_changes_push(args):
+    """
+    __resolve_uncommitted_changes_push(args)
+
+    Ensures the current checkout has no uncommitted changes that should be part of a propose or push.
+    """
+
+    status = __call(['git', 'status', '--porcelain'])
+    for line in status:
+        if not args.ignore and line[0] != '?':
+            print('You have local changes that have not been committed:')
+            print('')
+            for state in status:
+                print(state)
+            print('')
+            print("To ignore these uncommitted changes, repeat the {} with '--ignore'.".format(args.command))
+            exit(errno.EINVAL)
+
+
+def __resolve_remote(args):
+    """
+    __resolve_remote(args)
+
+    Identifies the git remote to use for fetching and pushing patchsets by parsing .git/config.
+    """
+
+    remotes = __call(['git', 'remote'])
+
+    if len(remotes) == 0:
+        # no remotes defined
+        print("Please define a Git remote")
+        exit(errno.EINVAL)
+    elif len(remotes) == 1:
+        # only one remote, use it
+        args.remote = remotes[0]
+        return
+    else:
+        # multiple remotes, read .git/config
+        output = __call(['git', 'config', '--local', 'patchsets.remote'], fail=False)
+        preferred = output[0] if len(output) > 0 else ''
+
+        if len(preferred) == 0:
+            print("You have multiple remote repositories and you have not configured 'patchsets.remote'.")
+            print("")
+            print("Available remote repositories:")
+            for remote in remotes:
+                print('  ' + remote)
+            print("")
+            print("Please set the remote repository to use for patchsets.")
+            print("  git config --local patchsets.remote <remote>")
+            exit(errno.EINVAL)
+        else:
+            try:
+                remotes.index(preferred)
+            except ValueError:
+                print("The '{}' repository specified in 'patchsets.remote' is not configured!".format(preferred))
+                print("")
+                print("Available remotes:")
+                for remote in remotes:
+                    print('  ' + remote)
+                print("")
+                print("Please set the remote repository to use for patchsets.")
+                print("  git config --local patchsets.remote <remote>")
+                exit(errno.EINVAL)
+
+            args.remote = preferred
+    return
+
+
+def __resolve_patchset(args):
+    """
+    __resolve_patchset(args)
+
+    Resolves the current patchset or validates the the specified patchset exists.
+    """
+    if args.patchset is None:
+        # resolve current patchset
+        args.patchset = __get_current_patchset(args.remote, args.id)
+
+        if args.patchset == 0:
+            # there are no patchsets for the ticket or the ticket does not exist
+            print("There are no patchsets for ticket {} in the '{}' repository".format(args.id, args.remote))
+            exit(errno.EINVAL)
+    else:
+        # validate specified patchset
+        args.patchset = __validate_patchset(args.remote, args.id, args.patchset)
+
+        if args.patchset == 0:
+            # there are no patchsets for the ticket or the ticket does not exist
+            print("Patchset {} for ticket {} can not be found in the '{}' repository".format(args.patchset, args.id, args.remote))
+            exit(errno.EINVAL)
+
+    return
+
+
+def __validate_patchset(remote, ticket, patchset):
+    """
+    __validate_patchset(remote, ticket, patchset)
+
+    Validates that the specified ticket patchset exists.
+    """
+
+    nps = 0
+    patchset_ref = 'refs/tickets/{:02d}/{:d}/{:d}'.format(ticket % 100, ticket, patchset)
+    for line in __call(['git', 'ls-remote', remote, patchset_ref]):
+        ps = int(line.split('/')[4])
+        if ps > nps:
+            nps = ps
+
+    if nps == patchset:
+        return patchset
+    return 0
+
+
+def __get_current_patchset(remote, ticket):
+    """
+    __get_current_patchset(remote, ticket)
+
+    Determines the most recent patchset for the ticket by listing the remote patchset refs
+    for the ticket and parsing the patchset numbers from the resulting set.
+    """
+
+    nps = 0
+    patchset_refs = 'refs/tickets/{:02d}/{:d}/*'.format(ticket % 100, ticket)
+    for line in __call(['git', 'ls-remote', remote, patchset_refs]):
+        ps = int(line.split('/')[4])
+        if ps > nps:
+            nps = ps
+
+    return nps
+
+
+def __checkout(remote, ticket, patchset, branch, force=False):
+    """
+    __checkout(remote, ticket, patchset, branch)
+    __checkout(remote, ticket, patchset, branch, force)
+
+    Checkout the patchset on a detached head or on a named branch.
+    """
+
+    has_branch = False
+    on_branch = False
+
+    if branch is None or len(branch) == 0:
+        # checkout the patchset on a detached head
+        print('Checking out ticket {} patchset {} on a detached HEAD'.format(ticket, patchset))
+        __call(['git', 'checkout', 'FETCH_HEAD'], echo=True)
+        return
+    else:
+        # checkout on named branch
+
+        # determine if we are already on the target branch
+        for line in __call(['git', 'branch', '--list', branch]):
+            has_branch = True
+            if line[0] == '*':
+                # current branch (* name)
+                on_branch = True
+
+        if not has_branch:
+            if force:
+                # force the checkout the patchset to the new named branch
+                # used when there are local changes to discard
+                print("Forcing checkout of ticket {} patchset {} on named branch '{}'".format(ticket, patchset, branch))
+                __call(['git', 'checkout', '-b', branch, 'FETCH_HEAD', '--force'], echo=True)
+            else:
+                # checkout the patchset to the new named branch
+                __call(['git', 'checkout', '-b', branch, 'FETCH_HEAD'], echo=True)
+            return
+
+        if not on_branch:
+            # switch to existing local branch
+            __call(['git', 'checkout', branch], echo=True)
+
+        #
+        # now we are on the local branch for the patchset
+        #
+
+        if force:
+            # reset HEAD to FETCH_HEAD, this drops any local changes
+            print("Forcing checkout of ticket {} patchset {} on named branch '{}'".format(ticket, patchset, branch))
+            __call(['git', 'reset', '--hard', 'FETCH_HEAD'], echo=True)
+            return
+        else:
+            # try to merge the existing ref with the FETCH_HEAD
+            merge = __call(['git', 'merge', '--ff-only', branch, 'FETCH_HEAD'], echo=True, fail=False)
+            if len(merge) is 1:
+                up_to_date = merge[0].lower().index('up-to-date') > 0
+                if up_to_date:
+                    return
+            elif len(merge) is 0:
+                print('')
+                print("Your '{}' branch has diverged from patchset {} on the '{}' repository.".format(branch, patchset, remote))
+                print('')
+                print("To discard your local changes, repeat the checkout with '--force'.")
+                print('NOTE: forcing a checkout will HARD RESET your working directory!')
+                exit(errno.EINVAL)
+    return
+
+
+def __get_pushref_params(args):
+    """
+    __get_pushref_params(args)
+
+    Returns the push ref parameters for ticket field assignments.
+    """
+
+    params = []
+
+    if args.milestone is not None:
+        params.append('m=' + args.milestone)
+
+    if args.topic is not None:
+        params.append('t=' + args.topic)
+    else:
+        for branch in __call(['git', 'branch']):
+            if branch[0] == '*':
+                b = branch[1:].strip()
+                if b.startswith('topic/'):
+                    topic = b[len('topic/'):]
+                    try:
+                        # ignore ticket id topics
+                        int(topic)
+                    except:
+                        # topic is a string
+                        params.append('t=' + topic)
+
+    if args.responsible is not None:
+        params.append('r=' + args.responsible)
+
+    if args.cc is not None:
+        for cc in args.cc:
+            params.append('cc=' + cc)
+
+    if len(params) > 0:
+        return '%' + ','.join(params)
+
+    return ''
+
+
+def __call(cmd_args, echo=False, fail=True, err=None):
+    """
+    __call(cmd_args)
+
+    Executes the specified command as a subprocess.  The output is parsed and returned as a list
+    of strings.  If the process returns a non-zero exit code, the script terminates with that
+    exit code.  Std err of the subprocess is passed-through to the std err of the parent process.
+    """
+
+    p = subprocess.Popen(cmd_args, stdout=subprocess.PIPE, stderr=err, universal_newlines=True)
+    lines = []
+    for line in iter(p.stdout.readline, b''):
+        line_str = str(line).strip()
+        if len(line_str) is 0:
+            break
+        lines.append(line_str)
+        if echo:
+            print(line_str)
+    p.wait()
+    if fail and p.returncode is not 0:
+        exit(p.returncode)
+
+    return lines
+
+#
+# define the acceptable arguments and their usage/descriptions
+#
+
+# force argument
+force_arg = argparse.ArgumentParser(add_help=False)
+force_arg.add_argument('-f', '--force', default=False, help='force the command to complete', action='store_true')
+
+# quiet argument
+quiet_arg = argparse.ArgumentParser(add_help=False)
+quiet_arg.add_argument('-q', '--quiet', default=False, help='suppress git stderr output', action='store_true')
+
+# ticket & patchset arguments
+ticket_args = argparse.ArgumentParser(add_help=False)
+ticket_args.add_argument('id', help='the ticket id', type=int)
+ticket_args.add_argument('-p', '--patchset', help='the patchset number', type=int)
+
+# push refspec arguments
+push_args = argparse.ArgumentParser(add_help=False)
+push_args.add_argument('-i', '--ignore', default=False, help='ignore uncommitted changes', action='store_true')
+push_args.add_argument('-m', '--milestone', help='set the milestone')
+push_args.add_argument('-r', '--responsible', help='set the responsible user')
+push_args.add_argument('-t', '--topic', help='set the topic')
+push_args.add_argument('-cc', nargs='+', help='specify accounts to add to the watch list')
+
+# the commands
+parser = argparse.ArgumentParser(description='a Patchset Tool for Gitblit Tickets')
+parser.add_argument('--version', action='version', version='%(prog)s {}'.format(__version__))
+commands = parser.add_subparsers(dest='command', title='commands')
+
+fetch_parser = commands.add_parser('fetch', help='fetch a patchset', parents=[ticket_args, quiet_arg])
+fetch_parser.set_defaults(func=fetch)
+
+checkout_parser = commands.add_parser('checkout', aliases=['co'],
+                                      help='fetch & checkout a patchset to a branch',
+                                      parents=[ticket_args, force_arg, quiet_arg])
+checkout_parser.set_defaults(func=checkout)
+
+pull_parser = commands.add_parser('pull',
+                                  help='fetch & merge a patchset into the current branch',
+                                  parents=[ticket_args, force_arg])
+pull_parser.add_argument('-s', '--squash',
+                         help='squash the pulled patchset into your working directory',
+                         default=False,
+                         action='store_true')
+pull_parser.set_defaults(func=pull)
+
+push_parser = commands.add_parser('push', aliases=['up'],
+                                  help='upload your patchset changes',
+                                  parents=[push_args, force_arg])
+push_parser.add_argument('id', help='the ticket id', nargs='?', type=int)
+push_parser.set_defaults(func=push)
+
+propose_parser = commands.add_parser('propose', help='propose a new ticket or the first patchset', parents=[push_args])
+propose_parser.add_argument('target', help="the ticket id, 'new', or the integration branch", nargs='?')
+propose_parser.set_defaults(func=propose)
+
+cleanup_parser = commands.add_parser('cleanup', aliases=['rm'],
+                                     help='remove local ticket branches',
+                                     parents=[force_arg])
+cleanup_parser.add_argument('id', help='the ticket id', nargs='?', type=int)
+cleanup_parser.set_defaults(func=cleanup)
+
+start_parser = commands.add_parser('start', help='start a new branch for the topic or ticket')
+start_parser.add_argument('topic', help="the topic or ticket id")
+start_parser.set_defaults(func=start)
+
+if len(sys.argv) < 2:
+    parser.parse_args(['--help'])
+else:
+    # parse the command-line arguments
+    script_args = parser.parse_args()
+
+    # exec the specified command
+    script_args.func(script_args)
diff --git a/src/main/java/pt.txt b/src/main/java/pt.txt
new file mode 100644 (file)
index 0000000..34703f1
--- /dev/null
@@ -0,0 +1,49 @@
+Barnum, a Patchset Tool (pt)
+
+This Git wrapper script is designed to reduce the ceremony of working with Gitblit patchsets.
+
+Copyright 2014 gitblit.com.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+
+Linux
+
+1. This script should work out-of-the-box, assuming you have Python 3 and Git.
+2. Put the pt script in a directory on your PATH
+
+Mac OS X
+
+1. Download and install Python 3, if you have not (http://www.python.org)
+2. Put the pt script in a directory on your PATH
+
+Windows
+
+1. Download and install Python 3, if you have not (http://www.python.org)
+2. Download and install Git for Windows, if you have not (http://git-scm.com)
+3. Put the pt.cmd and pt.py file together in a directory on your PATH
+
+
+Usage
+
+    pt fetch <id> [-p,--patchset <n>]
+    pt checkout <id> [-p,--patchset <n>] [-f,--force]
+    pt push [<id>] [-i,--ignore] [-f,--force] [-t,--topic <topic>]
+            [-m,--milestone <milestone>] [-cc <user> <user>]
+    pt pull <id>
+    pt start <topic> | <id>
+    pt propose [new | <branch> | <id>] [-i,--ignore] [-t,--topic <topic>]
+               [-m,--milestone <milestone>] [-cc <user> <user>]
+    pt cleanup [<id>]
+
+    
\ No newline at end of file
diff --git a/src/main/resources/barnum_32x32.png b/src/main/resources/barnum_32x32.png
new file mode 100644 (file)
index 0000000..e364909
Binary files /dev/null and b/src/main/resources/barnum_32x32.png differ
index cd5c57b001013a3d3cbbb3f1686f8beab87c6995..9c763783f7f6ae7496e8f5d1440f16308f704dfe 100644 (file)
@@ -38,11 +38,53 @@ a.bugtraq {
     font-weight: bold;\r
 }\r
 \r
+.label a.bugtraq {
+       font-weight: normal;
+       color: white;
+}
+
+.lwbadge {
+       color: #888;
+       font-size: 11px;
+       background-color: #e8e8e8;
+       padding: 1px 7px 2px;
+       -webkit-border-radius: 9px;
+       -moz-border-radius: 9px;
+       border-radius: 9px;
+       line-height: 14px;      
+       white-space: nowrap;
+       vertical-align: baseline;
+}
+
 [class^="icon-"], [class*=" icon-"] i {\r
        /* override for a links that look like bootstrap buttons */\r
        vertical-align: text-bottom;\r
 }\r
 \r
+.pagination-small, .pagination-small ul {
+       margin: 0px !important; 
+}
+
+.pagination-small ul {
+       border-right: 1px solid #ddd;
+}
+
+.pagination-small ul > li > a,
+.pagination-small ul > li > span {
+  padding: 2px 8px;
+  font-size: 13px;
+  line-height: 22px;
+  border: 1px solid #ddd;
+  border-right: 0px;
+  border-radius: 0px !important;
+  float: left;
+}
+
+.btn.disabled em, .pagination-small ul > li > span em {
+       font-style: normal;
+       color: #444;
+}
+
 hr {\r
        margin-top: 10px;\r
        margin-bottom: 10px;\r
@@ -137,7 +179,8 @@ navbar div>ul .menu-dropdown li a:hover,.nav .menu-dropdown li a:hover,.navbar d
        color: #ffffff !important;\r
 }\r
 \r
-.nav-pills > .active > a, .nav-pills > .active > a:hover {\r
+.nav-pills > .active > a, .nav-pills > .active > a:hover,
+.nav-list > .active > a, .nav-list > .active > a:hover {
     color: #fff;\r
     background-color: #002060;\r
 }\r
@@ -520,6 +563,24 @@ th {
        text-align: left;       \r
 }\r
 \r
+table.tickets {
+       border-bottom: 1px solid #ccc;  
+}
+
+table.tickets td.indicators {
+       width: 75px;
+       text-align: right;
+       padding-right: 5px;
+       color: #888;
+}
+
+.ticketLabel,
+table.tickets .label {
+       color: white;
+       font-weight: normal;
+       margin: 0px 2px;
+}
+
 div.featureWelcome {\r
        padding: 15px;\r
        background-color: #fbfbfb;\r
@@ -532,6 +593,30 @@ div.featureWelcome div.icon {
        font-size: 144px;\r
 }\r
 \r
+li.dynamicQuery {
+       padding: 3px 0px;
+       margin: 1px 0px;
+       border-radius: 4px;
+}
+
+li.dynamicQuery i {
+       color: rgba(255, 255, 255, 0.5);
+       padding-right: 5px;
+}
+
+li.dynamicQuery a.active {
+       color: white;
+}
+
+div.milestoneOverview {
+       color:#888;
+       border: 1px solid #ddd;
+       padding: 2px 5px;
+       text-align: center;
+       font-size: 11px;
+       background-color: #fbfbfb;
+}
+
 div.sourceview {\r
        overflow: hidden;\r
 }\r
@@ -619,6 +704,7 @@ pre.prettyprint ol {
        border: 1px solid #ccc;\r
        color: #ccc;\r
        font-weight:bold;\r
+       display: inline-block;
 }\r
 \r
 .diffstat-inline {\r
@@ -650,7 +736,207 @@ pre.prettyprint ol {
 .diffstat-delete {\r
        color: #B9583B; \r
 }\r
-\r
+.patch-group {
+       margin-bottom: 0px;
+       border: 1px solid #ccc;
+       background-color: #fbfbfb;
+}
+
+.patch-group .accordion-inner {
+       padding: 0px;
+}
+\r
+.ticket-meta-top {
+       padding: 0px 10px 10px 10px;
+}
+
+.ticket-meta-middle {
+       border: 1px solid #ccc;
+       padding: 10px;
+       background-color: #fbfbfb;
+}
+
+.ticket-meta-bottom {
+       border: 1px solid #ccc;
+       border-top: 0px;
+       padding: 10px;
+}
+
+.ticket-title {
+       font-size: 20px;
+}
+
+.ticket-number {
+       color: #ccc;
+       font-size: 20px;
+       font-weight: normal;
+}
+
+.ticket-list-icon {
+       padding: 8px 0px 8px 8px !important;
+       width: 24px;
+       font-size: 24px;
+       vertical-align: middle !important;
+       color: #888;
+}
+
+td.ticket-list-state {
+       vertical-align: middle;
+}
+
+.ticket-list-details {
+       font-size: 11px;
+       color: #888;
+}
+
+div.ticket-text {
+       max-width: 600px;
+}\r
+\r
+.ticket-text-editor {\r
+       height:7em;\r
+       border:0px;\r
+       border-radius: 0px;\r
+       border-top:1px solid #ccc;\r
+       margin-bottom:0px;\r
+       padding:4px;\r
+       background-color:#ffffff;\r
+       box-shadow: none;\r
+}
+
+.indicator-large-dark {
+       font-size: 20px;
+       color: #888;
+}
+
+.indicator-large-light {
+       font-size: 20px;
+       color: #bbb;
+}
+
+.indicator-huge-light {
+       font-size: 48px;
+       color: #bbb;
+}
+
+.attribution-emphasize {
+       font-weight: bold;
+}
+
+.attribution-text {
+       color: #888;
+}
+
+.attribution-border {  
+}
+
+.attribution-header {
+       background-color: #fbfbfb;
+       padding: 8px;
+       border: 1px solid #ccc;
+}
+
+.attribution-header-pullright {
+       float: right;
+       text-align: right;
+       padding-right: 1px;
+}
+
+.attribution-patch-pullright {
+       float: right;
+       text-align: right;
+       margin: 5px 10px;
+}
+
+.attribution-date {
+       color: #999;
+       font-size: smaller;
+}
+
+.attribution-link {
+       color: #999;
+       padding-left: 5px;
+}
+
+.attribution-pullright {
+       float: right;
+       text-align: right;
+       padding-right: 8px;
+}
+
+.attribution-triangle {
+       position: absolute;
+       margin-left: -23px;
+       margin-top: 11px;
+       height: 0px;
+       width: 0px;
+       border-image: none;
+       border: 10px solid transparent;
+       border-right: 13px solid #ddd;
+}
+
+.attribution-comment {
+       padding: 10px 10px 0px 10px;
+       /*border: 1px solid #ccc;
+       border-top: 0px;*/
+}
+
+.ticket-simple-event {
+       padding: 5px 0px;
+}
+
+.status-display {
+       text-align: center;
+       font-weight: bold;      
+}
+
+.status-change {
+       font-size: 1.0em;
+       text-shadow: none;
+       padding: 5px 10px !important;
+       font-weight: bold;
+       display: inline-block;
+       text-align: center;
+       width: 50px;
+       margin-right: 5px !important;
+}
+
+.submit-info {
+       margin-bottom: 0px;
+       border-radius: 0px;
+}
+
+.merge-panel {
+       padding: 5px 7px;       
+       background-color: #fbfbfb;
+       color: #444
+}
+
+.merge-panel p.step {
+       margin: 10px 0px 5px;
+}
+
+.gitcommand {
+       margin-top: 5px;
+       border: 1px solid #ccc;
+       background-color: #333 !important;
+       color: #ccc;
+       border-radius: 3px;
+       padding: 5px;
+       margin-bottom: 5px;
+       text-shadow: none;
+}
+
+a.commit {
+       border: 1px solid #ccc;
+       border-radius: 3px;
+       background-color: #fbfbfb;
+       padding: 2px 4px;       
+       line-heihgt:99%;
+       font-size: 11px;
+       text-transform: lowercase;
+}
+
 h1 small, h2 small, h3 small, h4 small, h5 small, h6 small {\r
     color: #888;\r
 }\r
@@ -727,6 +1013,12 @@ span.empty {
        color: #008000;\r
 }\r
 \r
+span.highlight {
+       background-color: rgb(255, 255, 100);
+       color: black;
+       padding: 0px 2px;
+}
+
 span.link {\r
        color: #888;\r
 }\r
@@ -775,11 +1067,17 @@ img.overview {
 \r
 img.gravatar {\r
     background-color: #ffffff;\r
-    border: 1px solid #ddd;\r
+    /*border: 1px solid #ddd;*/
     border-radius: 5px;\r
     padding: 2px;\r
 }\r
 \r
+img.gravatar-round {
+    background-color: #ffffff;
+    border: 1px solid #ccc;
+    border-radius: 100%;    
+}
+
 img.navbarGravatar {\r
        border: 1px solid #fff;\r
 }\r
@@ -1157,7 +1455,7 @@ div.references {
        text-align: right;\r
 }\r
 \r
-table.plain, table.summary {\r
+table.plain, table.summary, table.ticket {\r
        width: 0 !important;\r
        border: 0;\r
 }\r
@@ -1168,11 +1466,16 @@ table.plain th, table.plain td, table.summary th, table.summary td {
        border: 0;\r
 }\r
 \r
+table.ticket th, table.ticket td {\r
+       padding: 1px 3px;\r
+       border: 0;\r
+}\r
+\r
 table.summary {\r
        margin: 0px;\r
 }\r
 \r
-table.summary th {\r
+table.summary th, table.ticket th {\r
        color: #999;\r
        padding-right: 10px;\r
        text-align: right;\r
@@ -1662,4 +1965,105 @@ div.markdown table.text th, div.markdown table.text td {
        vertical-align: top;\r
        border-top: 1px solid #ccc;\r
        padding:5px;\r
+}
+.resolution {
+       text-transform: uppercase;
+       font-weight: bold !important;
+       font-size: 11px;
+}
+.resolution-success, .resolution-success a {
+       color: #14892c !important;
+}
+.resolution-success a:hover {
+       color: white !important;
+}
+.resolution-error, .resolution-error a {
+       color: #d04437 !important;
+}
+.resolution-error a:hover {
+       color: white !important;
+}
+.resolution-complete, .resolution-complete a {
+       color: #4a6785 !important
+}
+.resolution-complete a:hover {
+       color: white !important;
+}
+.resolution-current, .resolution-current a {
+       color: #594300 !important;
+}
+.resolution-current, .resolution-current a:hover {
+       color: white;
+}
+
+/*! AUI Lozenge */
+.aui-lozenge {
+    background: #ccc;
+    border: 1px solid #ccc;
+    border-radius: 3px;
+    color: #333;
+    display: inline-block;
+    font-size: 11px;
+    font-weight: bold;
+    line-height: 99%; /* cross-browser compromise to make the line-height match the font-size */
+    margin: 0;
+    padding: 2px 5px;
+    text-align: center;
+    text-decoration: none;
+    text-transform: uppercase;
+}
+.aui-lozenge.aui-lozenge-subtle {
+    background-color: #fff;
+    border-color: #ccc;
+    color: #333;
+}
+.aui-lozenge-success {
+    background-color: #14892c;
+    border-color: #14892c;
+    color: #fff;
+}
+.aui-lozenge-success.aui-lozenge-subtle {
+    background-color: #fff;
+    border-color: #b2d8b9;
+    color: #14892c;
+}
+.aui-lozenge-error {
+    background-color: #d04437;
+    border-color: #d04437;
+    color: #fff;
+}
+.aui-lozenge-error.aui-lozenge-subtle {
+    background-color: #fff;
+    border-color: #f8d3d1;
+    color: #d04437;
+}
+.aui-lozenge-current {
+    background-color: #ffd351;
+    border-color: #ffd351;
+    color: #594300;
+}
+.aui-lozenge-current.aui-lozenge-subtle {
+    background-color: #fff;
+    border-color: #ffe28c;
+    color: #594300;
+}
+.aui-lozenge-complete {
+    background-color: #4a6785;
+    border-color: #4a6785;
+    color: #fff;
+}
+.aui-lozenge-complete.aui-lozenge-subtle {
+    background-color: #fff;
+    border-color: #e4e8ed;
+    color: #4a6785;
+}
+.aui-lozenge-moved {
+    background-color: #815b3a;
+    border-color: #815b3a;
+    color: #fff;
+}
+.aui-lozenge-moved.aui-lozenge-subtle {
+    background-color: #fff;
+    border-color: #ece7e2;
+    color: #815b3a;
 }
\ No newline at end of file
index 1f1635a4cc409a0ebbd1b165b8582069e70cd314..9f77e30ab9f48284f5118e610e53764f5816276b 100644 (file)
@@ -53,6 +53,7 @@ The following dependencies are automatically downloaded by Gitblit GO (or alread
 - [libpam4j](https://github.com/kohsuke/libpam4j) (MIT)\r
 - [commons-codec](http://commons.apache.org/proper/commons-codec) (Apache 2.0)\r
 - [pegdown](https://github.com/sirthias/pegdown) (Apache 2.0)\r
+- [jedis](https://github.com/xetorthio/jedis) (MIT)\r
 \r
 ### Other Build Dependencies\r
 - [Fancybox image viewer](http://fancybox.net) (MIT and GPL dual-licensed)\r
diff --git a/src/site/tickets_barnum.mkd b/src/site/tickets_barnum.mkd
new file mode 100644 (file)
index 0000000..91f29b6
--- /dev/null
@@ -0,0 +1,79 @@
+## Barnum
+
+*PREVIEW 1.4.0*
+
+Barnum is a command-line companion for Git.  It's purpose is to simplify the syntax and ceremony for working with Gitblit Tickets and Patchsets.
+
+The current implementation is a Python script that wraps a native Git executable.  It requires Python 3 and native Git.  It works well on Windows, Linux, and Mac OS X.
+
+### Fetch
+
+    pt fetch <id> [-p,--patchset <n>]
+
+If *patchset* is specified, the **fetch** command will download the specified ticket patchset to the FETCH_HEAD ref.  If *patchset* is **not*** specified, the configured remote will be fetched to download all ticket branch updates - this is the same as <pre>git fetch {remote}</pre>.
+
+### Checkout (co)
+
+    pt checkout <id> [-p,--patchset <n>] [--force]
+
+The **checkout** command fetches and checks-out the patchset to a predetermined branch.
+
+If *patchset* is not specified, the current patchset is checked-out to `ticket/{id}`.  If *patchset* is specified, the patchset is checked-out to `patchset/{id}/{patchset}`.
+
+### Pull
+
+    pt pull <id> [-s,--squash]
+
+The **pull** command fetches and merges the ticket patchset into your current branch.
+
+You may specify the `--squash` flag to squash the pulled patchset into one commit.  This will leave your working directory dirty and you must stage and commit the pending changes yourself.
+
+### Push (up)
+
+    pt push [<id>] [--force] [-r, --responsible <user>] [-m,--milestone <milestone>] [-t,--topic <topic>] [-cc <user> <user>]
+
+The **push** command allows you to upload a fast-forward update to an existing patchset or to upload a rewrite of an existing patchset (amend, rebase, or squash).
+
+You may set several ticket fields during the push such as *milestone*, *topic*, and *responsible*.  Use the *cc* argument to add users to the watch list for the ticket.
+
+One thing to note about the *topic* field is that Gitblit will match the *topic* against the repository bugtraq configuration which allows you to link your ticket with an external issue tracker.
+
+### Start
+
+    pt start <topic>
+    pt start <id>
+
+The **start** command is used to start development of a topic branch that will eventually be pushed to a Ticket. 
+
+You must specify what you are starting.  If you specify a ticket id, the branch `topid/{id}` will be created.  If you specify a topic string, the branch `topic/{topic}` will be created.  The main difference will be how the **propose** command treats your branch name.
+
+### Propose
+
+    pt propose [new | <branch> | <id>] [-r, --responsible <user>] [-m,--milestone <milestone>] [-t,--topic <topic>] [-cc <user> <user>]
+
+The **propose** command pushes an initial patchset to an existing ticket OR allows you to create a new ticket from your patchset on push.
+
+If you created your topic branch with the **start** command and you specified an existing ticket id as what you were starting, then Barnum will identify the id from the branch name and assume that is the target ticket for your patchset.
+
+If you created your topic branch with the **start** command and you specified a topic string as what you were starting, Barnum will identify that and specify that as the *topic* push ref parameter, but will still require a proposal target: *new*, *branch*, or *id*.
+
+#### Create Ticket on Push
+
+In order to create a ticket from your patchset, your patchset *must* contain only a *single* commit.  The first line of the commit message will specify the ticket title.  The remainder of the commit message will define the ticket description.
+
+    Fix null pointer exception in StringUtils
+    
+    It is possible to throw a null pointer exception in the trim method.
+    This can be triggered by yada, yada, yada.
+
+After the ticket is created from the single commit, you can push as many additional commits as you want.  It is just the first push with one commit that is special.
+
+One helpful tip to note about the *topic* field is that Gitblit will match the *topic* against the repository bugtraq configuration which allows you to link your ticket with an external issue tracker.
+
+### Cleanup (rm)
+
+    pt cleanup <id> [--force]
+
+The **cleanup** command is used to delete ticket branches from your local repository.
+
+The *force* argument is necessary for **cleanup** to actually remove the local branches.  Running **cleanup** by itself will identify the branches that can be removed.
diff --git a/src/site/tickets_overview.mkd b/src/site/tickets_overview.mkd
new file mode 100644 (file)
index 0000000..f236e51
--- /dev/null
@@ -0,0 +1,145 @@
+## Tickets
+
+*PREVIEW 1.4.0*
+
+Gitblit's Tickets feature is analgous to GitHub/BitBucket Issues+Pull Requests.  Gitblit does not make a hard distinction between what is an Issue and what is a Pull Request.  In Gitblit, all tickets may have attached commits and there is no need to create a separate, new container to share & discuss these commits.  Additionally, there is no need to create multiple Tickets for different versions of the same code - a common practice in other systems.
+
+You can view a screencast of Gitblit Tickets in action [here](https://vimeo.com/86164723).
+
+### Design
+
+The Tickets feature of Gitblit is designed around a few principles:
+
+1. Tickets should be simple enough to use quickly to track action items or user reports
+2. Any ticket can contain commits shared by a contributor
+3. The ticket should be the canonical source of commits related to the ticket (i.e. a fork repository should not be the canonical source of commits)
+4. Additional contributors should be allowed to participate in developing the patchset for a ticket, not just the original patchset author.  The ticket should be a container for collaborative branch development, not just for code-review/gating.
+5. Contributors should be able to rewrite commits attached to a ticket without losing history.  Contributors should be encouraged to polish, hone, and rewrite as needed to ensure that what eventually is merged is logical and concise.
+
+Gitblit takes inspiration from GitHub, BitBucket, and Gerrit.
+
+#### Ticket Model
+
+Gitblit stores each ticket as a journal (list of changes).  A ticket journal is retrieved from the chosen persistence engine and an effective ticket is built by applying the ordered changes from the journal.  These changes are usually additive, but in some cases a change may represent a deletion.  Tickets are indexed by Lucene against which all ticket queries are executed.
+
+#### Collaboration Workflow
+
+Gitblit uses a 3-repository workflow.  This means that Gitblit cuts the *fork* repository out of the collaboration workflow: patchsets are pushed directly to a special branch of the canonical repository, not to a fork.  You may also push to fork, if you want, but all collaboration occurs in the canonical repository, not your fork.
+
+#### Persistence Choices
+
+Gitblit's ticket data is based on a ridiculously simple concept: a ticket is the end result of applying a sequence of changes to an empty ticket.  Each change is serialized as JSON and stored in a journal.  The journal may be a simple text file (`journal.json`) or it may be a Redis LIST or some future persistence type.
+
+All ticket services inherit from the same base class which handles most of the high level logic for ticket management including caching, milestones (stored in .git/config), indexing, queries, and searches.
+
+You can find descriptions of the available persistence services in the [setup][tickets_setup.mkd] page.
+
+#### Limitations
+
+- Ticket data is non-relational to user accounts.  If *james* comments on a ticket, *james* is preserved forever in the ticket data.  This is similar to git commits which are also non-relational.  This could be overcome by writing a tool to deserialize all the journals and rewrite the authors, so it is not impossible to change, but following KISS - ticket data is non-relational to user accounts.
+- The *Branch Ticket Service* does not currently permit ticket journal pushes from clones.  This is an area of exploration and may be possible given that a ticket is constructed from an append-only journal of changes.
+- Gitblit does not currently offer commit comments nor line comments, only overall ticket comments .
+
+#### How did GitHub influence the design of Tickets?
+
+**UI.** GitHub has a very efficient, and clean UI for their Issues.  It offers the basics and give you labels to fill in the gaps.  It is not overly complex.
+
+Gitblit's Ticket querying and discussion ui are modeled after GitHub's ui design.
+
+#### How did BitBucket influence the design of Tickets?
+
+**UI.** BitBucket has a more rigid issue tracker and a clean issue viewing ui.  The rigidity makes it more like a traditional issue tracker with status, priority, kind, etc.
+
+Gitblit's Ticket page ui is partially inspired by BitBucket.  Gitblit Tickets have state and types, which makes it a more rigid/traditional tracker.  Atlassian has also gifted the community with the AUI, a webapp toolkit of CSS & JS.  Gitblit has borrowed some of these Apache licensed CSS elements.
+
+**Branch Pull Requests.** BitBucket has a very cool feature of creating a pull request from a branch within the same repository.  GitHub may also be able to do this.  Gitblit does not currently allow you to create a ticket from an existing branch, but Gitblit tracks ticket commits using normal branches with the canonical repository.
+
+#### How did Gerrit influence the design of Tickets?
+
+**Patchsets.** Gerrit employs a clever patchset workflow that requires repeated use of `git commit --amend` to hone and polish a commit until it is ready for merging to the proposed integration branch.  This technique is a much improved analog of patch revision.
+
+After working with this design for many months and dogfooding dozens of tickets with hundreds of amends, rebases, and squashes, I have concluded that this workflow doesn't work like I wanted it to for active, in-development code.  It is best suited for it's original intention: code-review.  It also introduces many, many refs.
+
+Gitblit has adopted Gerrit's three-repository workflow and *magic ref* design for pushes of new ticket patchsets or rewrites of existing ticket patchsets.
+
+### Nomenclature
+
+1. The organizational unit of the Gitblit Tickets feature is the *ticket*.
+2. A *ticket* can be used to report a bug, request an enhancement, ask a question, etc.  A ticket can also be used to collaborate on a *patchset* that addresses the request.
+3. A *patchset* is a series of commits from a merge base that exists in the target branch of your repository to the tip of the patchset.  A patchset may only contain a single commit, or it may contain dozens.  This is similar to the commits in a *Pull Request*.  One important distinction here is that in Gitblit, each *Patchset* is developed on a separate branch and can be completely rewritten without losing the previous patchsets (this creates a new patchset).
+4. A *ticket* monitors the development of *patchsets* by tracking *revisions* to *patchsets*.  The ticket alslo monitors rewritten patchsets. Each *patchset* is developed on it's own Git branch.
+Tracking *patchsets* is similar in concept to Gerrit, but there is a critical difference.  In Gerrit, *every* commit in the *patchset* has it's own ticket  **AND** Git branch.  In Gerrit, *patchsets* can be easily rewritten and for each rewritten commit, a new branch ref is created.  This leads to an explosion in refs for the repository over time.  In Gitblit, only the tip of the *patchset* gets a branch ref and this branch ref is updated, like a regular branch, unless a rewrite is detected.
+
+If you prefer the Gerrit-style workflow, you can achieve a fair approximation by only pushing single commit patchsets and always amending them.  You will not be able to chain tickets together, like you can chain reviews in Gerrit.
+
+### Types of Tickets
+
+Gitblit has two primary ticket types with a subtle distinction between them.
+
+1. *Proposal Ticket*.  This ticket type is created when a contributor pushes a single commit to Gitblit using the **for** magic ref.  The title and body of the commit message become the title and description of the ticket.  If you want to adopt a Gerrit-style workflow then you may *--amend* this commit and push it again and again.  Each *--amend* and push will update the Ticket's title and description from the commit message.  However, if you push new commits that build on the initial commit then this title/description updating behavior will not apply.
+
+2. *Request Ticket*.  This is a ticket that is manually created by a user using the web ui.  These tickets have assignable types like *Bug*, *Enhancement*, *Task*, or *Question*.
+
+The only difference between these two ticket types is how they are created (on-push or through the ui) and the aforementioned special behavior of amending the initial commit.  Otherwise, both types are identical.
+
+### Why not GitHub-style Pull/Merge Requests?
+
+GitHub-style Pull Requests require the following workflow:
+
+1. Fork RepoA -> MyRepoA
+2. Clone MyRepoA
+3. Create branch in MyRepoA clone and hack on contribution
+4. Push new branch upstream to MyRepoA
+5. Open Pull Request from MyRepoA -> RepoA
+6. RepoA owner pulls from MyRepoA
+7. RepoA owner pushes merge to RepoA
+
+Gitblit's flow looks like this:
+
+1. Clone RepoA
+2. Create branch in RepoA clone and hack on contribution
+3. Push to magic branch of RepoA
+4. RepoA owner pulls from RepoA
+5. RepoA owner pushes merge to RepoA
+
+The Gitblit workflow eliminates the 4-repository design of a GitHub pull request (canonical, canonical working copy, fork, & fork working copy) in favor of a 3-repository design (canonical, canonical working copy, clone working copy).
+
+You might wonder: is it a good idea to allow users to push into the canonical repository?  And the answer is, it's no different than a GitHub pull request.  When you open a GitHub pull request from MyRepoA to RepoA, your code is already being pushed to a private branch in RepoA (*refs/pull/{id}/head* and *refs/pull/{id}/merge*) so effectively you are already pushing into RepoA - you are just using an extra repository and the web ui to do it.  By pushing directly to the canonical repository, you save server resources and eliminate the web ui step.
+
+Additionally, because the patchset is not linked to a user's personal fork it is possible to allow others to collaborate on development.
+
+## Status
+
+The Tickets feature is highly functional but there are several areas which need further refinements.
+
+#### What is working
+
+- Ticket creation and editing
+- Ticket creation on patchset push
+- Comments with Markdown syntax support
+- Rich email notifications
+- Fast-forward patchset updates and patchset rewrites
+- Voting
+- Watching
+- Mentions
+- Partial milestone support
+- Querying
+- Searching
+- Is Mergeable test on view ticket page load
+- Close-on-push of detected merge
+- Multiple backend choices
+- Server-side merge (testing)
+
+#### TODO
+
+- need a My Tickets page
+- web ui for adding, editing, and deleting miletones
+- continue cleanup of code and templates
+- would be nice to have a collapsible ticket description (e.g. AUI expander)
+- would be nice to edit a comment
+- would be nice to delete a comment
+- Groovy hook points major ticket changes (new, close, patchset change)
+- REST API for tooling
+- Might be nice to process Markdown previews client-side rather than round-tripping to Gitblit (another stateful example).  Perhaps using AngularMarkdown?
+- Would be nice to have a tool to import/export journals between services.  All the journals use the same format so this should be very straight-forward to migrate/convert them between services.
diff --git a/src/site/tickets_setup.mkd b/src/site/tickets_setup.mkd
new file mode 100644 (file)
index 0000000..b1fa775
--- /dev/null
@@ -0,0 +1,119 @@
+## Setting up Tickets
+
+*PREVIEW 1.4.0*
+
+By default, Gitblit is not configured for Tickets.  There are several reasons for this, but the most important one is that you must choose the persistence backend that works best for you.
+
+### tickets.service
+
+*RESTART REQUIRED*
+
+The hardest part of setting up Gitblit Tickets is deciding which backend to use.  Three implementations are provided, each with different strengths and weaknesses.
+
+#### File Ticket Service
+
+    tickets.service = com.gitblit.tickets.FileTicketService
+
+Your ticket journals are persisted to `tickets/{shard}/{id}/journal.json`.  These journals are stored on the filesystem within your .git directory.
+
+#### Branch Ticket Service
+
+    tickets.service = com.gitblit.tickets.BranchTicketService
+
+Your ticket journals are persisted to `id/{shard}/{id}/journal.json`.  These journals are stored on an orphan branch, `refs/gitblit/tickets`, within your repository.  This allows you to easily clone your entire ticket history to client working copies or to mirrors.
+
+#### Redis Ticket Service
+
+    tickets.service = com.gitblit.tickets.RedisTicketService
+
+Your ticket journals are persisted to a Redis data store.  *Make sure you configure your Redis instance for durability!!*  This particular service is highly-scalable and very fast.  Plus you can use all of the power of Redis replication, should you want.
+
+The main drawback to this service is that Redis is primarily a Unix tool and works best on a Unix server.  While there is a Windows port, sort-of maintained by Microsoft, it is not actively updated.
+
+    tickets.redis.url = redis://(:{password}@){hostname}:{port}(/{databaseId})
+
+**examples**
+
+    tickets.redis.url = redis://localhost:6379
+    tickets.redis.url = redis://:password@localhost:6379/2
+
+### Other Settings
+
+You should also review the following settings before using Gitblit Tickets to understand what controls are available.
+
+#### web.canonicalUrl
+
+    web.canonicalUrl = https://localhost:8443
+
+The Tickets feature sends rich email notifications to those who are participating or watching a ticket.  In order for the links in those emails to work properly, you really should set the canonical web url of your Gitblit install.  This url should be your public url used to browse and navigate the website.
+
+#### tickets.acceptNewTickets
+
+    tickets.acceptNewTickets = true
+
+This setting is used to globally disable manual creation of tickets through the web ui.  You may still create proposal tickets by pushing patchsets.
+
+You may decide to disable creation of new tickets at the repository level in the *Edit Repository* page, however if this global setting is false, it will trump the repository setting.
+
+#### tickets.acceptNewPatchsets
+
+    tickets.acceptNewPatchsets = true
+
+This setting is used to globally disable accepting new patchsets.  If this set false, you can not create proposal tickets BUT you can still create tickets through the web ui, assuming *tickets.acceptNewTickets=true*.
+
+You may decide to disable accepting new patchsets at the repository level in the *Edit Repository* page, however if this global setting is false it will trump the repository setting.
+
+#### tickets.requireApproval
+
+    tickets.requireApproval = false
+
+This setting is the default for requiring an approve review score (+2) before enabling the merge button in the web ui.  This setting is not considered during the push process so an integrator may push a merged ticket disregarding this approval setting.  The setting only affects the web ui and may be configured per-repository.
+
+#### tickets.indexFolder
+
+*RESTART REQUIRED*
+
+    tickets.indexFolder = ${baseFolder}/tickets/lucene
+
+This is the destination for the unified Lucene ticket index.  You probably won't need to change this, but it's configurable if the need arises.
+
+### Setting up a Repository
+
+#### Controlling Tickets
+
+Each repository can accept or reject tickets and/or patchsets by the repository settings.
+
+##### Issue-Tracker, no patchsets
+
+    allow new tickets = true
+    accept patchsets = false
+
+##### Proposals only, no user-reported issues
+
+    allow new tickets = false
+    accept patchsets = true
+
+##### Issue-tracker AND Proposals
+
+    allow new tickets = true
+    accept patchsets = true
+
+##### No tickets whatsoever
+
+    allow new tickets = false
+    accept patchsets = false
+
+#### Controlling Merges
+
+Gitblit has a simple review scoring mechanism designed to indicate overall impression of the patchset.  You may optionally configure your repository to require an approval score for a patchset revision BEFORE the Merge button is displayed/enabled.  This per-repository setting is not respected if an integrator pushes a merge.  This setting is only used to control the web ui.
+
+#### Milestones
+
+Milestones are a way to group tickets together.  Currently milestones are specified at the repository level and are stored in the repository git config file.  Gitblit's internal architecture has all the methods necessary to maintain milestones, but this functionality is not yet exposed through the web ui.  For now you will have to control milestones manually with a text editor.
+
+    [milestone "v1.5.0"]
+        status = Open
+        due = 2014-06-01
+        color = "#00f000"
+
+Please note the date format for the *due* field: yyyy-MM-dd.
diff --git a/src/site/tickets_using.mkd b/src/site/tickets_using.mkd
new file mode 100644 (file)
index 0000000..71a4ebe
--- /dev/null
@@ -0,0 +1,155 @@
+## Using Tickets
+
+*PREVIEW 1.4.0*
+
+### Creating Standard Tickets
+
+Standard tickets can be created using the web ui.  These ticket types include *Bug*, *Enhancement*, *task*, and *Question*.
+
+### Creating a Proposal Ticket
+
+Proposal tickets are created by pushing a patchset to the magic ref.  They can not be created from the web ui.
+
+*Why should I create a proposal ticket?*
+
+Because you are too lazy to create a ticket in the web ui first.  The proposal ticket is a convenience mechanism.  It allows you to propose changes using Git, not your browser.
+
+*Who can create a proposal ticket?*
+
+Any authenticated user who can clone your repository.
+
+    git checkout -b mytopic
+    ...add a single commit...
+    git push origin HEAD:refs/for/new
+    git branch --set-upstream-to={remote}/ticket/{id}
+
+### Creating the first Patchset for an Existing Ticket
+
+If you have an existing ticket that does **not*** yet have a proposed patchset you can push using the magic ref.
+
+*Who can create the first patchset for an existing ticket?*
+
+Any authenticated user who can clone your repository.
+
+    git checkout -b mytopic
+    ...add one or more commits...
+    git push origin HEAD:refs/for/{id}
+    git branch --set-upstream-to={remote}/ticket/{id}
+
+### Safely adding commits to a Patchset for an Existing Ticket
+
+*Who can add commits to an existing patchset?*
+
+1. The author of the ticket
+2. The author of the initial patchset
+3. The person set as *responsible*
+4. Any user with write (RW) permissions to the repository
+
+
+    git checkout ticket/{id}
+    ...add one or more commits...
+    git push
+
+### Rewriting a Patchset (amend, rebase, squash)
+
+*Who can rewrite a patchset?*
+
+See the above rules for who can add commits to a patchset. You do **not** need rewind (RW+) to the repository to push a non-fast-forward patchset.  Gitblit will detect the non-fast-forward update and create a new patchset ref.  This preserves the previous patchset.
+
+    git checkout ticket/{id}
+    ...amend, rebase, squash...
+    git push origin HEAD:refs/for/{id}
+
+### Ticket RefSpecs
+
+Gitblit supports two primary push ref specs: the magic ref and the patchset ref.
+
+#### to create a new proposal ticket
+
+| ref                  | description                                  |
+| :------------------- | :------------------------------------------- |
+| refs/for/new         | new proposal for the default branch          |
+| refs/for/default     | new proposal for the default branch          |
+| refs/for/{branch}    | new proposal for the specified branch        |
+
+#### to add a proposal patchset (first patchset) to an existing ticket
+
+| ref                  | description                                  |
+| :------------------- | :------------------------------------------- |
+| refs/for/{id}        | add new patchset to an existing ticket       |
+
+#### to add commits to an existing patchset
+
+| ref                          | description                          |
+| :--------------------------- | :----------------------------------- |
+| refs/heads/ticket/{id}       | fast-forward an existing patchset    |
+
+
+#### to rewrite a patchset (amend, rebase, squash)
+
+| magic ref            | description                                  |
+| :------------------- | :------------------------------------------- |
+| refs/for/{id}        | add new patchset to an existing ticket       |
+
+### Ticket RefSpec Tricks
+
+Gitblit supports setting some ticket fields from the push refspec.
+
+    refs/for/master%topic=bug/42,r=james,m=1.4.1,cc=dave,cc=mark
+
+| parameter | description                                                     |
+| :-------- | :-------------------------------------------------------------- |
+| t         | assign a *topic* to the ticket (matched against bugtraq config) |
+| r         | set the *responsible* user                                      |
+| m         | set the *milestone* for patchset integration                    |
+| cc        | add this account to the *watch* list (multiple ccs allowed)     |
+
+#### examples
+
+Create a new patchset for ticket *12*, add *james* and *mark* to the watch list, and set the topic to *issue-123* which will be regex-matched against the repository bugtraq configuration.
+
+    git push origin HEAD:refs/for/12%cc=james,cc=mark,t=issue-123
+
+Add some commits to ticket *123* patchset *5*.  Set the milestone to *1.4.1*.
+
+    git push origin HEAD:refs/heads/ticket/123/5%m=1.4.1
+
+### Merging Patchsets
+
+The Gitblit web ui offers a merge button which *should work* but is not fully tested.  Gitblit does verify that you can cleanly merge a patchset to the integration branch.
+
+There are complicated merge scenarios for which it may be best to merge using your Git client.  There are several ways to do this, here is a safe merge strategy which pulls into a new branch and then fast-forwards your integration branch, assuming you were happy with the pull (merge).
+
+    git pull origin master
+    git checkout -b ticket-{id} master
+    git pull origin ticket/{id}
+    git checkout master
+    git merge ticket-{id}
+    git push origin master
+
+### Closing Tickets on Push with a Completely New Patchset
+
+Gitblit will look for patchset references on pushes to normal branches.  If it finds a reference (like would be found in the previous merge instructions), the ticket is resolved as merged and everyone is notified.
+
+If you do not need to create a patchset for review, you can just push a commit to the integration branch that contains `fixes #1` or `closes #1` in the commit message.  Gitblit will identify the ticket, create a new patchset with that commit as the tip, and resolve the ticket as merged.  (And if the integration branch is not specified in the ticket - this is the case for a ticket without any existing patchsets - Gitblit will resolve the ticket as merged to the pushed branch).
+
+### Reopening Tickets with Patchsets
+
+Gitblit allows you to reopen a Ticket with a merged patchset.  Since Gitblit allows patchset rewrites and versions patchsets, this seems like a logical capability.  There is no need to create another ticket for a feature request or bug report if the merged commits did not actually resolve the ticket.
+
+This allows you to continue the discussion and create a new patchset that hopefully resolves the need.
+
+**NOTE:**  There is one caveat to this feature.  You can not push patchsets to a closed ticket; Gitblit will reject the push.  You must first reopen the ticket through the web ui before you may push your patchset update or new patchset.
+
+### Reviews
+
+Gitblit includes a very simple review scoring mechanism.
+
+- +2, approved: patchset can be merged
+- +1, looks good: someone else must approve for merge
+- -1, needs improvement: please do not merge
+- -2, vetoed: patchset may not be merged
+
+Only users with write (RW) permissions to the repository can give a +2 and -2 score.  Any other user is free to score +/-1.
+
+If the patchset is updated or rewritten, all reviews are reset; reviews apply to specific revisions of patchsets - they are not blanket approvals/disapprovals.
diff --git a/src/test/java/com/gitblit/tests/BranchTicketServiceTest.java b/src/test/java/com/gitblit/tests/BranchTicketServiceTest.java
new file mode 100644 (file)
index 0000000..4bd74f5
--- /dev/null
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2014 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.tests;
+
+import com.gitblit.IStoredSettings;
+import com.gitblit.manager.INotificationManager;
+import com.gitblit.manager.IRepositoryManager;
+import com.gitblit.manager.IRuntimeManager;
+import com.gitblit.manager.IUserManager;
+import com.gitblit.manager.NotificationManager;
+import com.gitblit.manager.RepositoryManager;
+import com.gitblit.manager.RuntimeManager;
+import com.gitblit.manager.UserManager;
+import com.gitblit.models.RepositoryModel;
+import com.gitblit.tickets.BranchTicketService;
+import com.gitblit.tickets.ITicketService;
+
+/**
+ * Tests the branch ticket service.
+ *
+ * @author James Moger
+ *
+ */
+public class BranchTicketServiceTest extends TicketServiceTest {
+
+       final RepositoryModel repo = new RepositoryModel("tickets/branch.git", null, null, null);
+
+       @Override
+       protected RepositoryModel getRepository() {
+               return repo;
+       }
+
+
+       @Override
+       protected ITicketService getService(boolean deleteAll) throws Exception {
+
+               IStoredSettings settings = getSettings(deleteAll);
+
+               IRuntimeManager runtimeManager = new RuntimeManager(settings).start();
+               INotificationManager notificationManager = new NotificationManager(settings).start();
+               IUserManager userManager = new UserManager(runtimeManager).start();
+               IRepositoryManager repositoryManager = new RepositoryManager(runtimeManager, userManager).start();
+
+               BranchTicketService service = new BranchTicketService(
+                               runtimeManager,
+                               notificationManager,
+                               userManager,
+                               repositoryManager).start();
+
+               if (deleteAll) {
+                       service.deleteAll(getRepository());
+               }
+               return service;
+       }
+}
diff --git a/src/test/java/com/gitblit/tests/FileTicketServiceTest.java b/src/test/java/com/gitblit/tests/FileTicketServiceTest.java
new file mode 100644 (file)
index 0000000..3cc2521
--- /dev/null
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2014 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.tests;
+
+import com.gitblit.IStoredSettings;
+import com.gitblit.manager.INotificationManager;
+import com.gitblit.manager.IRepositoryManager;
+import com.gitblit.manager.IRuntimeManager;
+import com.gitblit.manager.IUserManager;
+import com.gitblit.manager.NotificationManager;
+import com.gitblit.manager.RepositoryManager;
+import com.gitblit.manager.RuntimeManager;
+import com.gitblit.manager.UserManager;
+import com.gitblit.models.RepositoryModel;
+import com.gitblit.tickets.FileTicketService;
+import com.gitblit.tickets.ITicketService;
+
+/**
+ * Tests the file ticket service.
+ *
+ * @author James Moger
+ *
+ */
+public class FileTicketServiceTest extends TicketServiceTest {
+
+       final RepositoryModel repo = new RepositoryModel("tickets/file.git", null, null, null);
+
+       @Override
+       protected RepositoryModel getRepository() {
+               return repo;
+       }
+
+       @Override
+       protected ITicketService getService(boolean deleteAll) throws Exception {
+
+               IStoredSettings settings = getSettings(deleteAll);
+
+               IRuntimeManager runtimeManager = new RuntimeManager(settings).start();
+               INotificationManager notificationManager = new NotificationManager(settings).start();
+               IUserManager userManager = new UserManager(runtimeManager).start();
+               IRepositoryManager repositoryManager = new RepositoryManager(runtimeManager, userManager).start();
+
+               FileTicketService service = new FileTicketService(
+                               runtimeManager,
+                               notificationManager,
+                               userManager,
+                               repositoryManager).start();
+
+               if (deleteAll) {
+                       service.deleteAll(getRepository());
+               }
+               return service;
+       }
+}
index 9fe7312c066dd83d837c83f01d31de70199f9259..cba575d768bf525067f0a0a50c4988421c51ea52 100644 (file)
@@ -63,7 +63,8 @@ import com.gitblit.utils.JGitUtils;
                GitBlitTest.class, FederationTests.class, RpcTests.class, GitServletTest.class, GitDaemonTest.class,\r
                GroovyScriptTest.class, LuceneExecutorTest.class, RepositoryModelTest.class,\r
                FanoutServiceTest.class, Issue0259Test.class, Issue0271Test.class, HtpasswdAuthenticationTest.class,\r
-               ModelUtilsTest.class, JnaUtilsTest.class, LdapSyncServiceTest.class })\r
+               ModelUtilsTest.class, JnaUtilsTest.class, LdapSyncServiceTest.class, FileTicketServiceTest.class,
+               BranchTicketServiceTest.class, RedisTicketServiceTest.class  })
 public class GitBlitSuite {\r
 \r
        public static final File BASEFOLDER = new File("data");\r
@@ -106,6 +107,11 @@ public class GitBlitSuite {
                return getRepository("test/gitective.git");\r
        }\r
 \r
+       public static Repository getTicketsTestRepository() {
+               JGitUtils.createRepository(REPOSITORIES, "gb-tickets.git").close();
+               return getRepository("gb-tickets.git");
+       }
+
        private static Repository getRepository(String name) {\r
                try {\r
                        File gitDir = FileKey.resolve(new File(REPOSITORIES, name), FS.DETECTED);\r
diff --git a/src/test/java/com/gitblit/tests/RedisTicketServiceTest.java b/src/test/java/com/gitblit/tests/RedisTicketServiceTest.java
new file mode 100644 (file)
index 0000000..5a4bda7
--- /dev/null
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2014 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.tests;
+
+import com.gitblit.IStoredSettings;
+import com.gitblit.Keys;
+import com.gitblit.manager.INotificationManager;
+import com.gitblit.manager.IRepositoryManager;
+import com.gitblit.manager.IRuntimeManager;
+import com.gitblit.manager.IUserManager;
+import com.gitblit.manager.NotificationManager;
+import com.gitblit.manager.RepositoryManager;
+import com.gitblit.manager.RuntimeManager;
+import com.gitblit.manager.UserManager;
+import com.gitblit.models.RepositoryModel;
+import com.gitblit.tickets.ITicketService;
+import com.gitblit.tickets.RedisTicketService;
+
+/**
+ * Tests the Redis ticket service.
+ *
+ * @author James Moger
+ *
+ */
+public class RedisTicketServiceTest extends TicketServiceTest {
+
+       final RepositoryModel repo = new RepositoryModel("tickets/redis.git", null, null, null);
+
+       @Override
+       protected RepositoryModel getRepository() {
+               return repo;
+       }
+
+       @Override
+       protected IStoredSettings getSettings(boolean deleteAll) throws Exception {
+               IStoredSettings settings = super.getSettings(deleteAll);
+               settings.overrideSetting(Keys.tickets.redis.url, "redis://localhost:6379/10");
+               return settings;
+       }
+
+       @Override
+       protected ITicketService getService(boolean deleteAll) throws Exception {
+
+               IStoredSettings settings = getSettings(deleteAll);
+
+               IRuntimeManager runtimeManager = new RuntimeManager(settings).start();
+               INotificationManager notificationManager = new NotificationManager(settings).start();
+               IUserManager userManager = new UserManager(runtimeManager).start();
+               IRepositoryManager repositoryManager = new RepositoryManager(runtimeManager, userManager).start();
+
+               RedisTicketService service = new RedisTicketService(
+                               runtimeManager,
+                               notificationManager,
+                               userManager,
+                               repositoryManager).start();
+
+               if (deleteAll) {
+                       service.deleteAll(getRepository());
+               }
+               return service;
+       }
+}
diff --git a/src/test/java/com/gitblit/tests/TicketServiceTest.java b/src/test/java/com/gitblit/tests/TicketServiceTest.java
new file mode 100644 (file)
index 0000000..5f94a46
--- /dev/null
@@ -0,0 +1,351 @@
+/*\r
+ * Copyright 2014 gitblit.com.\r
+ *\r
+ * Licensed under the Apache License, Version 2.0 (the "License");\r
+ * you may not use this file except in compliance with the License.\r
+ * You may obtain a copy of the License at\r
+ *\r
+ *     http://www.apache.org/licenses/LICENSE-2.0\r
+ *\r
+ * Unless required by applicable law or agreed to in writing, software\r
+ * distributed under the License is distributed on an "AS IS" BASIS,\r
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r
+ * See the License for the specific language governing permissions and\r
+ * limitations under the License.\r
+ */\r
+package com.gitblit.tests;\r
+\r
+import java.io.File;\r
+import java.util.Date;\r
+import java.util.HashMap;\r
+import java.util.List;\r
+import java.util.Map;\r
+\r
+import org.apache.commons.io.FileUtils;\r
+import org.bouncycastle.util.Arrays;\r
+import org.junit.After;\r
+import org.junit.Before;\r
+import org.junit.Test;\r
+\r
+import com.gitblit.IStoredSettings;\r
+import com.gitblit.Keys;\r
+import com.gitblit.models.Mailing;\r
+import com.gitblit.models.RepositoryModel;\r
+import com.gitblit.models.TicketModel;\r
+import com.gitblit.models.TicketModel.Attachment;\r
+import com.gitblit.models.TicketModel.Change;\r
+import com.gitblit.models.TicketModel.Field;\r
+import com.gitblit.models.TicketModel.Patchset;\r
+import com.gitblit.models.TicketModel.Status;\r
+import com.gitblit.models.TicketModel.Type;\r
+import com.gitblit.tests.mock.MemorySettings;\r
+import com.gitblit.tickets.ITicketService;\r
+import com.gitblit.tickets.ITicketService.TicketFilter;\r
+import com.gitblit.tickets.QueryResult;\r
+import com.gitblit.tickets.TicketIndexer.Lucene;\r
+import com.gitblit.tickets.TicketLabel;\r
+import com.gitblit.tickets.TicketMilestone;\r
+import com.gitblit.tickets.TicketNotifier;\r
+import com.gitblit.utils.JGitUtils;\r
+\r
+/**\r
+ * Tests the mechanics of Gitblit ticket management.\r
+ *\r
+ * @author James Moger\r
+ *\r
+ */\r
+public abstract class TicketServiceTest extends GitblitUnitTest {\r
+\r
+       private ITicketService service;\r
+\r
+       protected abstract RepositoryModel getRepository();\r
+\r
+       protected abstract ITicketService getService(boolean deleteAll) throws Exception;\r
+\r
+       protected IStoredSettings getSettings(boolean deleteAll) throws Exception {\r
+               File dir = new File(GitBlitSuite.REPOSITORIES, getRepository().name);\r
+               if (deleteAll) {\r
+                       FileUtils.deleteDirectory(dir);\r
+                       JGitUtils.createRepository(GitBlitSuite.REPOSITORIES, getRepository().name).close();\r
+               }\r
+\r
+               File luceneDir = new File(dir, "tickets/lucene");\r
+               luceneDir.mkdirs();\r
+\r
+               Map<String, Object> map = new HashMap<String, Object>();\r
+               map.put(Keys.git.repositoriesFolder, GitBlitSuite.REPOSITORIES.getAbsolutePath());\r
+               map.put(Keys.tickets.indexFolder, luceneDir.getAbsolutePath());\r
+\r
+               IStoredSettings settings = new MemorySettings(map);\r
+               return settings;\r
+       }\r
+\r
+       @Before\r
+       public void setup() throws Exception {\r
+               service = getService(true);\r
+       }\r
+\r
+       @After\r
+       public void cleanup() {\r
+               service.stop();\r
+       }\r
+\r
+       @Test\r
+       public void testLifecycle() throws Exception {\r
+               // create and insert a ticket\r
+               Change c1 = newChange("testCreation() " + Long.toHexString(System.currentTimeMillis()));\r
+               TicketModel ticket = service.createTicket(getRepository(), c1);\r
+               assertTrue(ticket.number > 0);\r
+\r
+               // retrieve ticket and compare\r
+               TicketModel constructed = service.getTicket(getRepository(), ticket.number);\r
+               compare(ticket, constructed);\r
+\r
+               assertEquals(1, constructed.changes.size());\r
+\r
+               // C1: create the ticket\r
+               int changeCount = 0;\r
+               c1 = newChange("testUpdates() " + Long.toHexString(System.currentTimeMillis()));\r
+               ticket = service.createTicket(getRepository(), c1);\r
+               assertTrue(ticket.number > 0);\r
+               changeCount++;\r
+\r
+               constructed = service.getTicket(getRepository(), ticket.number);\r
+               compare(ticket, constructed);\r
+               assertEquals(1, constructed.changes.size());\r
+\r
+               // C2: set owner\r
+               Change c2 = new Change("C2");\r
+               c2.comment("I'll fix this");\r
+               c2.setField(Field.responsible, c2.author);\r
+               constructed = service.updateTicket(getRepository(), ticket.number, c2);\r
+               assertNotNull(constructed);\r
+               assertEquals(2, constructed.changes.size());\r
+               assertEquals(c2.author, constructed.responsible);\r
+               changeCount++;\r
+\r
+               // C3: add a note\r
+               Change c3 = new Change("C3");\r
+               c3.comment("yeah, this is working");\r
+               constructed = service.updateTicket(getRepository(), ticket.number, c3);\r
+               assertNotNull(constructed);\r
+               assertEquals(3, constructed.changes.size());\r
+               changeCount++;\r
+\r
+               if (service.supportsAttachments()) {\r
+                       // C4: add attachment\r
+                       Change c4 = new Change("C4");\r
+                       Attachment a = newAttachment();\r
+                       c4.addAttachment(a);\r
+                       constructed = service.updateTicket(getRepository(), ticket.number, c4);\r
+                       assertNotNull(constructed);\r
+                       assertTrue(constructed.hasAttachments());\r
+                       Attachment a1 = service.getAttachment(getRepository(), ticket.number, a.name);\r
+                       assertEquals(a.content.length, a1.content.length);\r
+                       assertTrue(Arrays.areEqual(a.content, a1.content));\r
+                       changeCount++;\r
+               }\r
+\r
+               // C5: close the issue\r
+               Change c5 = new Change("C5");\r
+               c5.comment("closing issue");\r
+               c5.setField(Field.status, Status.Resolved);\r
+               constructed = service.updateTicket(getRepository(), ticket.number, c5);\r
+               assertNotNull(constructed);\r
+               changeCount++;\r
+               assertTrue(constructed.isClosed());\r
+               assertEquals(changeCount, constructed.changes.size());\r
+\r
+               List<TicketModel> allTickets = service.getTickets(getRepository());\r
+               List<TicketModel> openTickets = service.getTickets(getRepository(), new TicketFilter() {\r
+                       @Override\r
+                       public boolean accept(TicketModel ticket) {\r
+                               return ticket.isOpen();\r
+                       }\r
+               });\r
+               List<TicketModel> closedTickets = service.getTickets(getRepository(), new TicketFilter() {\r
+                       @Override\r
+                       public boolean accept(TicketModel ticket) {\r
+                               return ticket.isClosed();\r
+                       }\r
+               });\r
+               assertTrue(allTickets.size() > 0);\r
+               assertEquals(1, openTickets.size());\r
+               assertEquals(1, closedTickets.size());\r
+\r
+               // build a new Lucene index\r
+               service.reindex(getRepository());\r
+               List<QueryResult> hits = service.searchFor(getRepository(), "working", 1, 10);\r
+               assertEquals(1, hits.size());\r
+\r
+               // reindex a ticket\r
+               ticket = allTickets.get(0);\r
+               Change change = new Change("reindex");\r
+               change.comment("this is a test of reindexing a ticket");\r
+               service.updateTicket(getRepository(), ticket.number, change);\r
+               ticket = service.getTicket(getRepository(), ticket.number);\r
+\r
+               hits = service.searchFor(getRepository(), "reindexing", 1, 10);\r
+               assertEquals(1, hits.size());\r
+\r
+               service.stop();\r
+               service = getService(false);\r
+\r
+               // Lucene field query\r
+               List<QueryResult> results = service.queryFor(Lucene.status.matches(Status.New.name()), 1, 10, Lucene.created.name(), true);\r
+               assertEquals(1, results.size());\r
+               assertTrue(results.get(0).title.startsWith("testCreation"));\r
+\r
+               // Lucene field query\r
+               results = service.queryFor(Lucene.status.matches(Status.Resolved.name()), 1, 10, Lucene.created.name(), true);\r
+               assertEquals(1, results.size());\r
+               assertTrue(results.get(0).title.startsWith("testUpdates"));\r
+\r
+               // delete all tickets\r
+               for (TicketModel aTicket : allTickets) {\r
+                       assertTrue(service.deleteTicket(getRepository(), aTicket.number, "D"));\r
+               }\r
+       }\r
+\r
+       @Test\r
+       public void testChangeComment() throws Exception {\r
+               // C1: create the ticket\r
+               Change c1 = newChange("testChangeComment() " + Long.toHexString(System.currentTimeMillis()));\r
+               TicketModel ticket = service.createTicket(getRepository(), c1);\r
+               assertTrue(ticket.number > 0);\r
+               assertTrue(ticket.changes.get(0).hasComment());\r
+\r
+               ticket = service.updateComment(ticket, c1.comment.id, "E1", "I changed the comment");\r
+               assertNotNull(ticket);\r
+               assertTrue(ticket.changes.get(0).hasComment());\r
+               assertEquals("I changed the comment", ticket.changes.get(0).comment.text);\r
+\r
+               assertTrue(service.deleteTicket(getRepository(), ticket.number, "D"));\r
+       }\r
+\r
+       @Test\r
+       public void testDeleteComment() throws Exception {\r
+               // C1: create the ticket\r
+               Change c1 = newChange("testDeleteComment() " + Long.toHexString(System.currentTimeMillis()));\r
+               TicketModel ticket = service.createTicket(getRepository(), c1);\r
+               assertTrue(ticket.number > 0);\r
+               assertTrue(ticket.changes.get(0).hasComment());\r
+\r
+               ticket = service.deleteComment(ticket, c1.comment.id, "D1");\r
+               assertNotNull(ticket);\r
+               assertEquals(1, ticket.changes.size());\r
+               assertFalse(ticket.changes.get(0).hasComment());\r
+\r
+               assertTrue(service.deleteTicket(getRepository(), ticket.number, "D"));\r
+       }\r
+\r
+       @Test\r
+       public void testMilestones() throws Exception {\r
+               service.createMilestone(getRepository(), "M1", "james");\r
+               service.createMilestone(getRepository(), "M2", "frank");\r
+               service.createMilestone(getRepository(), "M3", "joe");\r
+\r
+               List<TicketMilestone> milestones = service.getMilestones(getRepository(), Status.Open);\r
+               assertEquals("Unexpected open milestones count", 3, milestones.size());\r
+\r
+               for (TicketMilestone milestone : milestones) {\r
+                       milestone.status = Status.Resolved;\r
+                       milestone.due = new Date();\r
+                       assertTrue("failed to update milestone " + milestone.name, service.updateMilestone(getRepository(), milestone, "ted"));\r
+               }\r
+\r
+               milestones = service.getMilestones(getRepository(), Status.Open);\r
+               assertEquals("Unexpected open milestones count", 0, milestones.size());\r
+\r
+               milestones = service.getMilestones(getRepository(), Status.Resolved);\r
+               assertEquals("Unexpected resolved milestones count", 3, milestones.size());\r
+\r
+               for (TicketMilestone milestone : milestones) {\r
+                       assertTrue("failed to delete milestone " + milestone.name, service.deleteMilestone(getRepository(), milestone.name, "lucifer"));\r
+               }\r
+       }\r
+\r
+       @Test\r
+       public void testLabels() throws Exception {\r
+               service.createLabel(getRepository(), "L1", "james");\r
+               service.createLabel(getRepository(), "L2", "frank");\r
+               service.createLabel(getRepository(), "L3", "joe");\r
+\r
+               List<TicketLabel> labels = service.getLabels(getRepository());\r
+               assertEquals("Unexpected open labels count", 3, labels.size());\r
+\r
+               for (TicketLabel label : labels) {\r
+                       label.color = "#ffff00";\r
+                       assertTrue("failed to update label " + label.name, service.updateLabel(getRepository(), label, "ted"));\r
+               }\r
+\r
+               labels = service.getLabels(getRepository());\r
+               assertEquals("Unexpected labels count", 3, labels.size());\r
+\r
+               for (TicketLabel label : labels) {\r
+                       assertTrue("failed to delete label " + label.name, service.deleteLabel(getRepository(), label.name, "lucifer"));\r
+               }\r
+       }\r
+\r
+\r
+\r
+       private Change newChange(String summary) {\r
+               Change change = new Change("C1");\r
+               change.setField(Field.title, summary);\r
+               change.setField(Field.body, "this is my description");\r
+               change.setField(Field.labels, "helpdesk");\r
+               change.comment("my comment");\r
+               return change;\r
+       }\r
+\r
+       private Attachment newAttachment() {\r
+               Attachment attachment = new Attachment("test1.txt");\r
+               attachment.content = new byte[] { 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49,\r
+                               0x4a };\r
+               return attachment;\r
+       }\r
+\r
+       private void compare(TicketModel ticket, TicketModel constructed) {\r
+               assertEquals(ticket.number, constructed.number);\r
+               assertEquals(ticket.createdBy, constructed.createdBy);\r
+               assertEquals(ticket.responsible, constructed.responsible);\r
+               assertEquals(ticket.title, constructed.title);\r
+               assertEquals(ticket.body, constructed.body);\r
+               assertEquals(ticket.created, constructed.created);\r
+\r
+               assertTrue(ticket.hasLabel("helpdesk"));\r
+       }\r
+\r
+       @Test\r
+       public void testNotifier() throws Exception {\r
+               Change kernel = new Change("james");\r
+               kernel.setField(Field.title, "Sample ticket");\r
+               kernel.setField(Field.body, "this **is** my sample body\n\n- I hope\n- you really\n- *really* like it");\r
+               kernel.setField(Field.status, Status.New);\r
+               kernel.setField(Field.type, Type.Proposal);\r
+\r
+               kernel.comment("this is a sample comment on a kernel change");\r
+\r
+               Patchset patchset = new Patchset();\r
+               patchset.insertions = 100;\r
+               patchset.deletions = 10;\r
+               patchset.number = 1;\r
+               patchset.rev = 25;\r
+               patchset.tip = "50f57913f816d04a16b7407134de5d8406421f37";\r
+               kernel.patchset = patchset;\r
+\r
+               TicketModel ticket = service.createTicket(getRepository(), 0L, kernel);\r
+\r
+               Change merge = new Change("james");\r
+               merge.setField(Field.mergeSha, patchset.tip);\r
+               merge.setField(Field.mergeTo, "master");\r
+               merge.setField(Field.status, Status.Merged);\r
+\r
+               ticket = service.updateTicket(getRepository(), ticket.number, merge);\r
+               ticket.repository = getRepository().name;\r
+\r
+               TicketNotifier notifier = service.createNotifier();\r
+               Mailing mailing = notifier.queueMailing(ticket);\r
+               assertNotNull(mailing);\r
+       }\r
+}
\ No newline at end of file