James Moger
2013-12-09 5e3521f8496511db4df45f011ea72f25623ad90f
Ticket tracker with patchset contributions

A basic issue tracker styled as a hybrid of GitHub and BitBucket issues.
You may attach commits to an existing ticket or you can push a single
commit to create a *proposal* ticket.

Tickets keep track of patchsets (one or more commits) and allow patchset
rewriting (rebase, amend, squash) by detecing the non-fast-forward
update and assigning a new patchset number to the new commits.

Ticket tracker
--------------

The ticket tracker stores tickets as an append-only journal of changes.
The journals are deserialized and a ticket is built by applying the
journal entries. Tickets are indexed using Apache Lucene and all
queries and searches are executed against this Lucene index.

There is one trade-off to this persistence design: user attributions are
non-relational.

What does that mean? Each journal entry stores the username of the
author. If the username changes in the user service, the journal entry
will not reflect that change because the values are hard-coded.

Here are a few reasons/justifications for this design choice:

1. commit identifications (author, committer, tagger) are non-relational
2. maintains the KISS principle
3. your favorite text editor can still be your administration tool

Persistence Choices
-------------------

**FileTicketService**: stores journals on the filesystem
**BranchTicketService**: stores journals on an orphan branch
**RedisTicketService**: stores journals in a Redis key-value datastore

It should be relatively straight-forward to develop other backends
(MongoDB, etc) as long as the journal design is preserved.

Pushing Commits
---------------

Each push to a ticket is identified as a patchset revision. A patchset
revision may add commits to the patchset (fast-forward) OR a patchset
revision may rewrite history (rebase, squash, rebase+squash, or amend).
Patchset authors should not be afraid to polish, revise, and rewrite
their code before merging into the proposed branch.

Gitblit will create one ref for each patchset. These refs are updated
for fast-forward pushes or created for rewrites. They are formatted as
`refs/tickets/{shard}/{id}/{patchset}`. The *shard* is the last two
digits of the id. If the id < 10, prefix a 0. The *shard* is always
two digits long. The shard's purpose is to ensure Gitblit doesn't
exceed any filesystem directory limits for file creation.

**Creating a Proposal Ticket**

You may create a new change proposal ticket just by pushing a **single
commit** to `refs/for/{branch}` where branch is the proposed integration
branch OR `refs/for/new` or `refs/for/default` which both will use the
default repository branch.

git push origin HEAD:refs/for/new

**Updating a Patchset**

The safe way to update an existing patchset is to push to the patchset
ref.

git push origin HEAD:refs/heads/ticket/{id}

This ensures you do not accidentally create a new patchset in the event
that the patchset was updated after you last pulled.

The not-so-safe way to update an existing patchset is to push using the
magic ref.

git push origin HEAD:refs/for/{id}

This push ref will update an exisitng patchset OR create a new patchset
if the update is non-fast-forward.

**Rebasing, Squashing, Amending**

Gitblit makes rebasing, squashing, and amending patchsets easy.

Normally, pushing a non-fast-forward update would require rewind (RW+)
repository permissions. Gitblit provides a magic ref which will allow
ticket participants to rewrite a ticket patchset as long as the ticket
is open.

git push origin HEAD:refs/for/{id}

Pushing changes to this ref allows the patchset authors to rebase,
squash, or amend the patchset commits without requiring client-side use
of the *--force* flag on push AND without requiring RW+ permission to
the repository. Since each patchset is tracked with a ref it is easy to
recover from accidental non-fast-forward updates.

Features
--------

- Ticket tracker with status changes and responsible assignments
- Patchset revision scoring mechanism
- Update/Rewrite patchset handling
- Close-on-push detection
- Server-side Merge button for simple merges
- Comments with Markdown syntax support
- Rich mail notifications
- Voting
- Mentions
- Watch lists
- Querying
- Searches
- Partial miletones support
- Multiple backend options
51 files added
38 files modified
17049 ■■■■■ changed files
.classpath 2 ●●●●● patch | view | raw | blame | history
NOTICE 17 ●●●●● patch | view | raw | blame | history
build.moxie 1 ●●●● patch | view | raw | blame | history
build.xml 14 ●●●●● patch | view | raw | blame | history
gitblit.iml 22 ●●●●● patch | view | raw | blame | history
releases.moxie 6 ●●●●● patch | view | raw | blame | history
src/main/distrib/data/clientapps.json 12 ●●●●● patch | view | raw | blame | history
src/main/distrib/data/gitblit.properties 70 ●●●●● patch | view | raw | blame | history
src/main/distrib/linux/reindex-tickets.sh 15 ●●●●● patch | view | raw | blame | history
src/main/distrib/win/reindex-tickets.cmd 13 ●●●●● patch | view | raw | blame | history
src/main/java/WEB-INF/web.xml 16 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/Constants.java 8 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/GitBlit.java 154 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/ReindexTickets.java 183 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/client/EditRepositoryDialog.java 21 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/git/GitblitReceivePack.java 11 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/git/GitblitReceivePackFactory.java 11 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/git/PatchsetCommand.java 324 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/git/PatchsetReceivePack.java 1129 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/manager/GitblitManager.java 10 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/manager/IGitblit.java 8 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/manager/RepositoryManager.java 12 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/models/RepositoryModel.java 11 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/models/TicketModel.java 1286 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/models/UserModel.java 12 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/servlet/PtServlet.java 201 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/tickets/BranchTicketService.java 799 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/tickets/FileTicketService.java 467 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/tickets/ITicketService.java 1088 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/tickets/NullTicketService.java 129 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/tickets/QueryBuilder.java 222 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/tickets/QueryResult.java 114 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/tickets/RedisTicketService.java 534 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/tickets/TicketIndexer.java 657 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/tickets/TicketLabel.java 77 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/tickets/TicketMilestone.java 53 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/tickets/TicketNotifier.java 617 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/tickets/TicketResponsible.java 59 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/tickets/TicketSerializer.java 175 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/tickets/commands.md 11 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/tickets/email.css 38 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/utils/JGitUtils.java 207 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/utils/JsonUtils.java 4 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/utils/MarkdownUtils.java 4 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/utils/RefLogUtils.java 40 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/GitBlitWebApp.java 15 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/GitBlitWebApp.properties 141 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/BasePage.java 25 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.html 31 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.java 11 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/EditTicketPage.html 66 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/EditTicketPage.java 290 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/ExportTicketPage.java 82 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/NewTicketPage.html 66 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/NewTicketPage.java 202 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/NoTicketsPage.html 21 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/NoTicketsPage.java 44 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/RepositoryPage.html 1 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/RepositoryPage.java 20 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/TicketBasePage.java 124 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/TicketPage.html 577 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/TicketPage.java 1527 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/TicketsPage.html 215 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/TicketsPage.java 878 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/propose_git.md 6 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/propose_pt.md 5 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/panels/CommentPanel.html 29 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/panels/CommentPanel.java 110 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/panels/DigestsPanel.java 539 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/panels/GravatarImage.java 142 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/panels/MarkdownTextArea.java 118 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/panels/ReflogPanel.html 4 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/panels/ReflogPanel.java 638 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/panels/RefsPanel.java 27 ●●●● patch | view | raw | blame | history
src/main/java/pt.cmd 1 ●●●● patch | view | raw | blame | history
src/main/java/pt.py 701 ●●●●● patch | view | raw | blame | history
src/main/java/pt.txt 49 ●●●●● patch | view | raw | blame | history
src/main/resources/barnum_32x32.png patch | view | raw | blame | history
src/main/resources/gitblit.css 412 ●●●●● patch | view | raw | blame | history
src/site/design.mkd 1 ●●●● patch | view | raw | blame | history
src/site/tickets_barnum.mkd 79 ●●●●● patch | view | raw | blame | history
src/site/tickets_overview.mkd 145 ●●●●● patch | view | raw | blame | history
src/site/tickets_setup.mkd 119 ●●●●● patch | view | raw | blame | history
src/site/tickets_using.mkd 155 ●●●●● patch | view | raw | blame | history
src/test/java/com/gitblit/tests/BranchTicketServiceTest.java 68 ●●●●● patch | view | raw | blame | history
src/test/java/com/gitblit/tests/FileTicketServiceTest.java 67 ●●●●● patch | view | raw | blame | history
src/test/java/com/gitblit/tests/GitBlitSuite.java 8 ●●●● patch | view | raw | blame | history
src/test/java/com/gitblit/tests/RedisTicketServiceTest.java 75 ●●●●● patch | view | raw | blame | history
src/test/java/com/gitblit/tests/TicketServiceTest.java 351 ●●●●● patch | view | raw | blame | history
.classpath
@@ -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" />
NOTICE
@@ -326,4 +326,19 @@
   SIL OFL 1.1.
   
   https://github.com/FortAwesome/Font-Awesome
---------------------------------------------------------------------------
AUI (excerpts)
---------------------------------------------------------------------------
   AUI, release under the
   Apache License 2.0
   https://bitbucket.org/atlassian/aui
---------------------------------------------------------------------------
Jedis
---------------------------------------------------------------------------
   Jedis, release under the
   MIT license
   https://github.com/xetorthio/jedis
build.moxie
@@ -170,6 +170,7 @@
- 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
build.xml
@@ -566,6 +566,13 @@
                        <page name="eclipse plugin" src="eclipse_plugin.mkd" />
                    </menu>
                    <divider />
                    <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" />
                    <divider />
                    <page name="settings" src="properties.mkd" />
@@ -890,6 +897,13 @@
                            <page name="eclipse plugin" src="eclipse_plugin.mkd" />
                        </menu>
                        <divider />
                        <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" />
                        <divider />
                        <page name="settings" src="properties.mkd" />
gitblit.iml
@@ -724,6 +724,28 @@
        </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>
releases.moxie
@@ -59,6 +59,7 @@
    - 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 @@
    - 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 @@
    - { 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
src/main/distrib/data/clientapps.json
@@ -10,6 +10,17 @@
        "isActive": true
    },
    {
        "name": "Barnum",
        "title": "Barnum",
        "description": "a command-line Git companion for Gitblit Tickets",
        "legal": "released under the Apache 2.0 License",
        "command": "pt clone ${repoUrl}",
        "productUrl": "http://barnum.gitblit.com",
        "transports": [ "ssh" ],
        "icon": "barnum_32x32.png",
        "isActive": false
    },
    {
        "name": "SmartGit/Hg",
        "title": "syntevo SmartGit/Hg\u2122",
        "description": "a Git client for Windows, Mac, & Linux",
@@ -73,6 +84,7 @@
        "legal": "released under the GPLv3 open source license",
        "cloneUrl": "sparkleshare://addProject/${baseUrl}/sparkleshare/${repoUrl}.xml",
        "productUrl": "http://sparkleshare.org",
        "transports": [ "ssh" ],
        "platforms": [ "windows", "macintosh", "linux" ],
        "icon": "sparkleshare_32x32.png",
        "minimumPermission" : "RW+",
src/main/distrib/data/gitblit.properties
@@ -429,6 +429,76 @@
# RESTART REQUIRED
git.packedGitMmap = false
# Use the Gitblit patch receive pack for processing contributions and tickets.
# This allows the user to push a patch using the familiar Gerrit syntax:
#
#    git push <remote> HEAD:refs/for/<targetBranch>
#
# NOTE:
# This requires git.enableGitServlet = true AND it requires an authenticated
# git transport connection (http/https) when pushing from a client.
#
# Valid services include:
#    com.gitblit.tickets.FileTicketService
#    com.gitblit.tickets.BranchTicketService
#    com.gitblit.tickets.RedisTicketService
#
# SINCE 1.4.0
# RESTART REQUIRED
tickets.service =
# Globally enable or disable creation of new bug, enhancement, task, etc tickets
# for all repositories.
#
# If false, no tickets can be created through the ui for any repositories.
# If true, each repository can control if they allow new tickets to be created.
#
# NOTE:
# If a repository is accepting patchsets, new proposal tickets can be created
# regardless of this setting.
#
# SINCE 1.4.0
tickets.acceptNewTickets = true
# Globally enable or disable pushing patchsets to all repositories.
#
# If false, no patchsets will be accepted for any repositories.
# If true, each repository can control if they accept new patchsets.
#
# NOTE:
# If a repository is accepting patchsets, new proposal tickets can be created
# regardless of the acceptNewTickets setting.
#
# SINCE 1.4.0
tickets.acceptNewPatchsets = true
# Default setting to control patchset merge through the web ui.  If true, patchsets
# must have an approval score to enable the merge button.  This setting can be
# overriden per-repository.
#
# SINCE 1.4.0
tickets.requireApproval = false
# Specify the location of the Lucene Ticket index
#
# SINCE 1.4.0
# RESTART REQUIRED
tickets.indexFolder = ${baseFolder}/tickets/lucene
# Define the url for the Redis server.
#
# e.g. redis://localhost:6379
#      redis://:foobared@localhost:6379/2
#
# SINCE 1.4.0
# RESTART REQUIRED
tickets.redis.url =
# The number of tickets to display on a page.
#
# SINCE 1.4.0
tickets.perPage = 25
#
# Groovy Integration
#
src/main/distrib/linux/reindex-tickets.sh
New file
@@ -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
src/main/distrib/win/reindex-tickets.cmd
New file
@@ -0,0 +1,13 @@
@REM --------------------------------------------------------------------------
@REM This is for reindexing Tickets with Lucene.
@REM
@REM Since the Tickets feature is undergoing massive churn it may be necessary
@REM to reindex tickets due to model or index changes.
@REM
@REM Always use forward-slashes for the path separator in your parameters!!
@REM
@REM Set FOLDER to the baseFolder.
@REM --------------------------------------------------------------------------
@SET FOLDER=data
@java -cp gitblit.jar;"%CD%\ext\*" com.gitblit.ReindexTickets --baseFolder %FOLDER%
src/main/java/WEB-INF/web.xml
@@ -161,6 +161,20 @@
        <url-pattern>/logo.png</url-pattern>
    </servlet-mapping>
    <!-- PT Servlet
         <url-pattern> MUST match:
            * Wicket Filter ignorePaths parameter -->
    <servlet>
        <servlet-name>PtServlet</servlet-name>
        <servlet-class>com.gitblit.servlet.PtServlet</servlet-class>
    </servlet>
    <servlet-mapping>
        <servlet-name>PtServlet</servlet-name>
        <url-pattern>/pt</url-pattern>
    </servlet-mapping>
    <!-- Branch Graph Servlet
         <url-pattern> MUST match: 
            * Wicket Filter ignorePaths parameter -->
@@ -300,7 +314,7 @@
                 * PagesFilter <url-pattern>
                 * PagesServlet <url-pattern>
                 * com.gitblit.Constants.PAGES_PATH -->
            <param-value>r/,git/,feed/,zip/,federation/,rpc/,pages/,robots.txt,logo.png,graph/,sparkleshare/</param-value>
            <param-value>r/,git/,pt,feed/,zip/,federation/,rpc/,pages/,robots.txt,logo.png,graph/,sparkleshare/</param-value>
        </init-param>
    </filter>
    <filter-mapping>
src/main/java/com/gitblit/Constants.java
@@ -108,12 +108,18 @@
    public static final String R_CHANGES = "refs/changes/";
    public static final String R_PULL= "refs/pull/";
    public static final String R_PULL = "refs/pull/";
    public static final String R_TAGS = "refs/tags/";
    public static final String R_REMOTES = "refs/remotes/";
    public static final String R_FOR = "refs/for/";
    public static final String R_TICKET = "refs/heads/ticket/";
    public static final String R_TICKETS_PATCHSETS = "refs/tickets/";
    public static String getVersion() {
        String v = Constants.class.getPackage().getImplementationVersion();
        if (v == null) {
src/main/java/com/gitblit/GitBlit.java
@@ -19,12 +19,14 @@
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,7 +36,16 @@
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
@@ -45,7 +56,11 @@
 */
public class GitBlit extends GitblitManager {
    private final ObjectGraph injector;
    private final ServicesManager servicesManager;
    private ITicketService ticketService;
    public GitBlit(
            IRuntimeManager runtimeManager,
@@ -64,6 +79,8 @@
                projectManager,
                federationManager);
        this.injector = ObjectGraph.create(getModules());
        this.servicesManager = new ServicesManager(this);
    }
@@ -72,6 +89,7 @@
        super.start();
        logger.info("Starting services manager...");
        servicesManager.start();
        configureTicketService();
        return this;
    }
@@ -79,7 +97,12 @@
    public GitBlit stop() {
        super.stop();
        servicesManager.stop();
        ticketService.stop();
        return this;
    }
    protected Object [] getModules() {
        return new Object [] { new GitBlitModule()};
    }
    /**
@@ -131,4 +154,135 @@
        }
        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;
        }
    }
}
src/main/java/com/gitblit/ReindexTickets.java
New file
@@ -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;
    }
}
src/main/java/com/gitblit/client/EditRepositoryDialog.java
@@ -88,6 +88,12 @@
    private JTextField descriptionField;
    private JCheckBox acceptNewPatchsets;
    private JCheckBox acceptNewTickets;
    private JCheckBox requireApproval;
    private JCheckBox useIncrementalPushTags;
    private JCheckBox showRemoteBranches;
@@ -205,6 +211,12 @@
        ownersPalette = new JPalette<String>(true);
        acceptNewTickets = new JCheckBox(Translation.get("gb.acceptsNewTicketsDescription"),
                anRepository.acceptNewTickets);
        acceptNewPatchsets = new JCheckBox(Translation.get("gb.acceptsNewPatchsetsDescription"),
                anRepository.acceptNewPatchsets);
        requireApproval = new JCheckBox(Translation.get("gb.requireApprovalDescription"),
                anRepository.requireApproval);
        useIncrementalPushTags = new JCheckBox(Translation.get("gb.useIncrementalPushTagsDescription"),
                anRepository.useIncrementalPushTags);
        showRemoteBranches = new JCheckBox(
@@ -298,6 +310,12 @@
        fieldsPanel.add(newFieldPanel(Translation.get("gb.gcPeriod"), gcPeriod));
        fieldsPanel.add(newFieldPanel(Translation.get("gb.gcThreshold"), gcThreshold));
        fieldsPanel.add(newFieldPanel(Translation.get("gb.acceptsNewTickets"),
                acceptNewTickets));
        fieldsPanel.add(newFieldPanel(Translation.get("gb.acceptsNewPatchsets"),
                acceptNewPatchsets));
        fieldsPanel.add(newFieldPanel(Translation.get("gb.requireApproval"),
                requireApproval));
        fieldsPanel
        .add(newFieldPanel(Translation.get("gb.enableIncrementalPushTags"), useIncrementalPushTags));
        fieldsPanel.add(newFieldPanel(Translation.get("gb.showRemoteBranches"),
@@ -552,6 +570,9 @@
                : headRefField.getSelectedItem().toString();
        repository.gcPeriod = (Integer) gcPeriod.getSelectedItem();
        repository.gcThreshold = gcThreshold.getText();
        repository.acceptNewPatchsets = acceptNewPatchsets.isSelected();
        repository.acceptNewTickets = acceptNewTickets.isSelected();
        repository.requireApproval = requireApproval.isSelected();
        repository.useIncrementalPushTags = useIncrementalPushTags.isSelected();
        repository.showRemoteBranches = showRemoteBranches.isSelected();
        repository.skipSizeCalculation = skipSizeCalculation.isSelected();
src/main/java/com/gitblit/git/GitblitReceivePack.java
@@ -50,6 +50,7 @@
import com.gitblit.manager.IGitblit;
import com.gitblit.models.RepositoryModel;
import com.gitblit.models.UserModel;
import com.gitblit.tickets.BranchTicketService;
import com.gitblit.utils.ArrayUtils;
import com.gitblit.utils.ClientLogger;
import com.gitblit.utils.CommitCache;
@@ -236,6 +237,16 @@
                default:
                    break;
                }
            } else if (ref.equals(BranchTicketService.BRANCH)) {
                // ensure pushing user is an administrator OR an owner
                // i.e. prevent ticket tampering
                boolean permitted = user.canAdmin() || repository.isOwner(user.username);
                if (!permitted) {
                    sendRejection(cmd, "{0} is not permitted to push to {1}", user.username, ref);
                }
            } else if (ref.startsWith(Constants.R_FOR)) {
                // prevent accidental push to refs/for
                sendRejection(cmd, "{0} is not configured to receive patchsets", repository.name);
            }
        }
src/main/java/com/gitblit/git/GitblitReceivePackFactory.java
@@ -100,10 +100,17 @@
        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);
src/main/java/com/gitblit/git/PatchsetCommand.java
New file
@@ -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);
    }
}
src/main/java/com/gitblit/git/PatchsetReceivePack.java
New file
@@ -0,0 +1,1129 @@
/*
 * 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 static org.eclipse.jgit.transport.BasePackPushConnection.CAPABILITY_SIDE_BAND_64K;
import java.io.IOException;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.eclipse.jgit.lib.AnyObjectId;
import org.eclipse.jgit.lib.BatchRefUpdate;
import org.eclipse.jgit.lib.NullProgressMonitor;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.ProgressMonitor;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.RefUpdate;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevSort;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.transport.ReceiveCommand;
import org.eclipse.jgit.transport.ReceiveCommand.Result;
import org.eclipse.jgit.transport.ReceiveCommand.Type;
import org.eclipse.jgit.transport.ReceivePack;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.gitblit.Constants;
import com.gitblit.Keys;
import com.gitblit.manager.IGitblit;
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.PatchsetType;
import com.gitblit.models.TicketModel.Status;
import com.gitblit.models.UserModel;
import com.gitblit.tickets.ITicketService;
import com.gitblit.tickets.TicketMilestone;
import com.gitblit.tickets.TicketNotifier;
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.JGitUtils.MergeResult;
import com.gitblit.utils.JGitUtils.MergeStatus;
import com.gitblit.utils.RefLogUtils;
import com.gitblit.utils.StringUtils;
/**
 * PatchsetReceivePack processes receive commands and allows for creating, updating,
 * and closing Gitblit tickets.  It also executes Groovy pre- and post- receive
 * hooks.
 *
 * The patchset mechanism defined in this class is based on the ReceiveCommits class
 * from the Gerrit code review server.
 *
 * The general execution flow is:
 * <ol>
 *    <li>onPreReceive()</li>
 *    <li>executeCommands()</li>
 *    <li>onPostReceive()</li>
 * </ol>
 *
 * @author Android Open Source Project
 * @author James Moger
 *
 */
public class PatchsetReceivePack extends GitblitReceivePack {
    protected static final List<String> MAGIC_REFS = Arrays.asList(Constants.R_FOR, Constants.R_TICKET);
    protected static final Pattern NEW_PATCHSET =
            Pattern.compile("^refs/tickets/(?:[0-9a-zA-Z][0-9a-zA-Z]/)?([1-9][0-9]*)(?:/new)?$");
    private static final Logger LOGGER = LoggerFactory.getLogger(PatchsetReceivePack.class);
    protected final ITicketService ticketService;
    protected final TicketNotifier ticketNotifier;
    private boolean requireCleanMerge;
    public PatchsetReceivePack(IGitblit gitblit, Repository db, RepositoryModel repository, UserModel user) {
        super(gitblit, db, repository, user);
        this.ticketService = gitblit.getTicketService();
        this.ticketNotifier = ticketService.createNotifier();
    }
    /** Returns the patchset ref root from the ref */
    private String getPatchsetRef(String refName) {
        for (String patchRef : MAGIC_REFS) {
            if (refName.startsWith(patchRef)) {
                return patchRef;
            }
        }
        return null;
    }
    /** Checks if the supplied ref name is a patchset ref */
    private boolean isPatchsetRef(String refName) {
        return !StringUtils.isEmpty(getPatchsetRef(refName));
    }
    /** Checks if the supplied ref name is a change ref */
    private boolean isTicketRef(String refName) {
        return refName.startsWith(Constants.R_TICKETS_PATCHSETS);
    }
    /** Extracts the integration branch from the ref name */
    private String getIntegrationBranch(String refName) {
        String patchsetRef = getPatchsetRef(refName);
        String branch = refName.substring(patchsetRef.length());
        if (branch.indexOf('%') > -1) {
            branch = branch.substring(0, branch.indexOf('%'));
        }
        String defaultBranch = "master";
        try {
            defaultBranch = getRepository().getBranch();
        } catch (Exception e) {
            LOGGER.error("failed to determine default branch for " + repository.name, e);
        }
        long ticketId = 0L;
        try {
            ticketId = Long.parseLong(branch);
        } catch (Exception e) {
            // not a number
        }
        if (ticketId > 0 || branch.equalsIgnoreCase("default") || branch.equalsIgnoreCase("new")) {
            return defaultBranch;
        }
        return branch;
    }
    /** Extracts the ticket id from the ref name */
    private long getTicketId(String refName) {
        if (refName.startsWith(Constants.R_FOR)) {
            String ref = refName.substring(Constants.R_FOR.length());
            if (ref.indexOf('%') > -1) {
                ref = ref.substring(0, ref.indexOf('%'));
            }
            try {
                return Long.parseLong(ref);
            } catch (Exception e) {
                // not a number
            }
        } else if (refName.startsWith(Constants.R_TICKET) ||
                refName.startsWith(Constants.R_TICKETS_PATCHSETS)) {
            return PatchsetCommand.getTicketNumber(refName);
        }
        return 0L;
    }
    /** Returns true if the ref namespace exists */
    private boolean hasRefNamespace(String ref) {
        Map<String, Ref> blockingFors;
        try {
            blockingFors = getRepository().getRefDatabase().getRefs(ref);
        } catch (IOException err) {
            sendError("Cannot scan refs in {0}", repository.name);
            LOGGER.error("Error!", err);
            return true;
        }
        if (!blockingFors.isEmpty()) {
            sendError("{0} needs the following refs removed to receive patchsets: {1}",
                    repository.name, blockingFors.keySet());
            return true;
        }
        return false;
    }
    /** Removes change ref receive commands */
    private List<ReceiveCommand> excludeTicketCommands(Collection<ReceiveCommand> commands) {
        List<ReceiveCommand> filtered = new ArrayList<ReceiveCommand>();
        for (ReceiveCommand cmd : commands) {
            if (!isTicketRef(cmd.getRefName())) {
                // this is not a ticket ref update
                filtered.add(cmd);
            }
        }
        return filtered;
    }
    /** Removes patchset receive commands for pre- and post- hook integrations */
    private List<ReceiveCommand> excludePatchsetCommands(Collection<ReceiveCommand> commands) {
        List<ReceiveCommand> filtered = new ArrayList<ReceiveCommand>();
        for (ReceiveCommand cmd : commands) {
            if (!isPatchsetRef(cmd.getRefName())) {
                // this is a non-patchset ref update
                filtered.add(cmd);
            }
        }
        return filtered;
    }
    /**    Process receive commands EXCEPT for Patchset commands. */
    @Override
    public void onPreReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
        Collection<ReceiveCommand> filtered = excludePatchsetCommands(commands);
        super.onPreReceive(rp, filtered);
    }
    /**    Process receive commands EXCEPT for Patchset commands. */
    @Override
    public void onPostReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
        Collection<ReceiveCommand> filtered = excludePatchsetCommands(commands);
        super.onPostReceive(rp, filtered);
        // send all queued ticket notifications after processing all patchsets
        ticketNotifier.sendAll();
    }
    @Override
    protected void validateCommands() {
        // workaround for JGit's awful scoping choices
        //
        // set the patchset refs to OK to bypass checks in the super implementation
        for (final ReceiveCommand cmd : filterCommands(Result.NOT_ATTEMPTED)) {
            if (isPatchsetRef(cmd.getRefName())) {
                if (cmd.getType() == ReceiveCommand.Type.CREATE) {
                    cmd.setResult(Result.OK);
                }
            }
        }
        super.validateCommands();
    }
    /** Execute commands to update references. */
    @Override
    protected void executeCommands() {
        // workaround for JGit's awful scoping choices
        //
        // reset the patchset refs to NOT_ATTEMPTED (see validateCommands)
        for (ReceiveCommand cmd : filterCommands(Result.OK)) {
            if (isPatchsetRef(cmd.getRefName())) {
                cmd.setResult(Result.NOT_ATTEMPTED);
            }
        }
        List<ReceiveCommand> toApply = filterCommands(Result.NOT_ATTEMPTED);
        if (toApply.isEmpty()) {
            return;
        }
        ProgressMonitor updating = NullProgressMonitor.INSTANCE;
        boolean sideBand = isCapabilityEnabled(CAPABILITY_SIDE_BAND_64K);
        if (sideBand) {
            SideBandProgressMonitor pm = new SideBandProgressMonitor(msgOut);
            pm.setDelayStart(250, TimeUnit.MILLISECONDS);
            updating = pm;
        }
        BatchRefUpdate batch = getRepository().getRefDatabase().newBatchUpdate();
        batch.setAllowNonFastForwards(isAllowNonFastForwards());
        batch.setRefLogIdent(getRefLogIdent());
        batch.setRefLogMessage("push", true);
        ReceiveCommand patchsetRefCmd = null;
        PatchsetCommand patchsetCmd = null;
        for (ReceiveCommand cmd : toApply) {
            if (Result.NOT_ATTEMPTED != cmd.getResult()) {
                // Already rejected by the core receive process.
                continue;
            }
            if (isPatchsetRef(cmd.getRefName())) {
                if (ticketService == null) {
                    sendRejection(cmd, "Sorry, the ticket service is unavailable and can not accept patchsets at this time.");
                    continue;
                }
                if (!ticketService.isReady()) {
                    sendRejection(cmd, "Sorry, the ticket service can not accept patchsets at this time.");
                    continue;
                }
                if (UserModel.ANONYMOUS.equals(user)) {
                    // server allows anonymous pushes, but anonymous patchset
                    // contributions are prohibited by design
                    sendRejection(cmd, "Sorry, anonymous patchset contributions are prohibited.");
                    continue;
                }
                final Matcher m = NEW_PATCHSET.matcher(cmd.getRefName());
                if (m.matches()) {
                    // prohibit pushing directly to a patchset ref
                    long id = getTicketId(cmd.getRefName());
                    sendError("You may not directly push directly to a patchset ref!");
                    sendError("Instead, please push to one the following:");
                    sendError(" - {0}{1,number,0}", Constants.R_FOR, id);
                    sendError(" - {0}{1,number,0}", Constants.R_TICKET, id);
                    sendRejection(cmd, "protected ref");
                    continue;
                }
                if (hasRefNamespace(Constants.R_FOR)) {
                    // the refs/for/ namespace exists and it must not
                    LOGGER.error("{} already has refs in the {} namespace",
                            repository.name, Constants.R_FOR);
                    sendRejection(cmd, "Sorry, a repository administrator will have to remove the {} namespace", Constants.R_FOR);
                    continue;
                }
                if (patchsetRefCmd != null) {
                    sendRejection(cmd, "You may only push one patchset at a time.");
                    continue;
                }
                // responsible verification
                String responsible = PatchsetCommand.getSingleOption(cmd, PatchsetCommand.RESPONSIBLE);
                if (!StringUtils.isEmpty(responsible)) {
                    UserModel assignee = gitblit.getUserModel(responsible);
                    if (assignee == null) {
                        // no account by this name
                        sendRejection(cmd, "{0} can not be assigned any tickets because there is no user account by that name", responsible);
                        continue;
                    } else if (!assignee.canPush(repository)) {
                        // account does not have RW permissions
                        sendRejection(cmd, "{0} ({1}) can not be assigned any tickets because the user does not have RW permissions for {2}",
                                assignee.getDisplayName(), assignee.username, repository.name);
                        continue;
                    }
                }
                // milestone verification
                String milestone = PatchsetCommand.getSingleOption(cmd, PatchsetCommand.MILESTONE);
                if (!StringUtils.isEmpty(milestone)) {
                    TicketMilestone milestoneModel = ticketService.getMilestone(repository, milestone);
                    if (milestoneModel == null) {
                        // milestone does not exist
                        sendRejection(cmd, "Sorry, \"{0}\" is not a valid milestone!", milestone);
                        continue;
                    }
                }
                // watcher verification
                List<String> watchers = PatchsetCommand.getOptions(cmd, PatchsetCommand.WATCH);
                if (!ArrayUtils.isEmpty(watchers)) {
                    for (String watcher : watchers) {
                        UserModel user = gitblit.getUserModel(watcher);
                        if (user == null) {
                            // watcher does not exist
                            sendRejection(cmd, "Sorry, \"{0}\" is not a valid username for the watch list!", watcher);
                            continue;
                        }
                    }
                }
                patchsetRefCmd = cmd;
                patchsetCmd = preparePatchset(cmd);
                if (patchsetCmd != null) {
                    batch.addCommand(patchsetCmd);
                }
                continue;
            }
            batch.addCommand(cmd);
        }
        if (!batch.getCommands().isEmpty()) {
            try {
                batch.execute(getRevWalk(), updating);
            } catch (IOException err) {
                for (ReceiveCommand cmd : toApply) {
                    if (cmd.getResult() == Result.NOT_ATTEMPTED) {
                        sendRejection(cmd, "lock error: {0}", err.getMessage());
                    }
                }
            }
        }
        //
        // set the results into the patchset ref receive command
        //
        if (patchsetRefCmd != null && patchsetCmd != null) {
            if (!patchsetCmd.getResult().equals(Result.OK)) {
                // patchset command failed!
                LOGGER.error(patchsetCmd.getType() + " " + patchsetCmd.getRefName()
                        + " " + patchsetCmd.getResult());
                patchsetRefCmd.setResult(patchsetCmd.getResult(), patchsetCmd.getMessage());
            } else {
                // all patchset commands were applied
                patchsetRefCmd.setResult(Result.OK);
                // update the ticket branch ref
                RefUpdate ru = updateRef(patchsetCmd.getTicketBranch(), patchsetCmd.getNewId());
                updateReflog(ru);
                TicketModel ticket = processPatchset(patchsetCmd);
                if (ticket != null) {
                    ticketNotifier.queueMailing(ticket);
                }
            }
        }
        //
        // if there are standard ref update receive commands that were
        // successfully processed, process referenced tickets, if any
        //
        List<ReceiveCommand> allUpdates = ReceiveCommand.filter(batch.getCommands(), Result.OK);
        List<ReceiveCommand> refUpdates = excludePatchsetCommands(allUpdates);
        List<ReceiveCommand> stdUpdates = excludeTicketCommands(refUpdates);
        if (!stdUpdates.isEmpty()) {
            int ticketsProcessed = 0;
            for (ReceiveCommand cmd : stdUpdates) {
                switch (cmd.getType()) {
                case CREATE:
                case UPDATE:
                case UPDATE_NONFASTFORWARD:
                    Collection<TicketModel> tickets = processMergedTickets(cmd);
                    ticketsProcessed += tickets.size();
                    for (TicketModel ticket : tickets) {
                        ticketNotifier.queueMailing(ticket);
                    }
                    break;
                default:
                    break;
                }
            }
            if (ticketsProcessed == 1) {
                sendInfo("1 ticket updated");
            } else if (ticketsProcessed > 1) {
                sendInfo("{0} tickets updated", ticketsProcessed);
            }
        }
        // reset the ticket caches for the repository
        ticketService.resetCaches(repository);
    }
    /**
     * Prepares a patchset command.
     *
     * @param cmd
     * @return the patchset command
     */
    private PatchsetCommand preparePatchset(ReceiveCommand cmd) {
        String branch = getIntegrationBranch(cmd.getRefName());
        long number = getTicketId(cmd.getRefName());
        TicketModel ticket = null;
        if (number > 0 && ticketService.hasTicket(repository, number)) {
            ticket = ticketService.getTicket(repository, number);
        }
        if (ticket == null) {
            if (number > 0) {
                // requested ticket does not exist
                sendError("Sorry, {0} does not have ticket {1,number,0}!", repository.name, number);
                sendRejection(cmd, "Invalid ticket number");
                return null;
            }
        } else {
            if (ticket.isMerged()) {
                // ticket already merged & resolved
                Change mergeChange = null;
                for (Change change : ticket.changes) {
                    if (change.isMerge()) {
                        mergeChange = change;
                        break;
                    }
                }
                sendError("Sorry, {0} already merged {1} from ticket {2,number,0} to {3}!",
                        mergeChange.author, mergeChange.patchset, number, ticket.mergeTo);
                sendRejection(cmd, "Ticket {0,number,0} already resolved", number);
                return null;
            } else if (!StringUtils.isEmpty(ticket.mergeTo)) {
                // ticket specifies integration branch
                branch = ticket.mergeTo;
            }
        }
        final int shortCommitIdLen = settings.getInteger(Keys.web.shortCommitIdLength, 6);
        final String shortTipId = cmd.getNewId().getName().substring(0, shortCommitIdLen);
        final RevCommit tipCommit = JGitUtils.getCommit(getRepository(), cmd.getNewId().getName());
        final String forBranch = branch;
        RevCommit mergeBase = null;
        Ref forBranchRef = getAdvertisedRefs().get(Constants.R_HEADS + forBranch);
        if (forBranchRef == null || forBranchRef.getObjectId() == null) {
            // unknown integration branch
            sendError("Sorry, there is no integration branch named ''{0}''.", forBranch);
            sendRejection(cmd, "Invalid integration branch specified");
            return null;
        } else {
            // determine the merge base for the patchset on the integration branch
            String base = JGitUtils.getMergeBase(getRepository(), forBranchRef.getObjectId(), tipCommit.getId());
            if (StringUtils.isEmpty(base)) {
                sendError("");
                sendError("There is no common ancestry between {0} and {1}.", forBranch, shortTipId);
                sendError("Please reconsider your proposed integration branch, {0}.", forBranch);
                sendError("");
                sendRejection(cmd, "no merge base for patchset and {0}", forBranch);
                return null;
            }
            mergeBase = JGitUtils.getCommit(getRepository(), base);
        }
        // ensure that the patchset can be cleanly merged right now
        MergeStatus status = JGitUtils.canMerge(getRepository(), tipCommit.getName(), forBranch);
        switch (status) {
        case ALREADY_MERGED:
            sendError("");
            sendError("You have already merged this patchset.", forBranch);
            sendError("");
            sendRejection(cmd, "everything up-to-date");
            return null;
        case MERGEABLE:
            break;
        default:
            if (ticket == null || requireCleanMerge) {
                sendError("");
                sendError("Your patchset can not be cleanly merged into {0}.", forBranch);
                sendError("Please rebase your patchset and push again.");
                sendError("NOTE:", number);
                sendError("You should push your rebase to refs/for/{0,number,0}", number);
                sendError("");
                sendError("  git push origin HEAD:refs/for/{0,number,0}", number);
                sendError("");
                sendRejection(cmd, "patchset not mergeable");
                return null;
            }
        }
        // check to see if this commit is already linked to a ticket
        long id = identifyTicket(tipCommit, false);
        if (id > 0) {
            sendError("{0} has already been pushed to ticket {1,number,0}.", shortTipId, id);
            sendRejection(cmd, "everything up-to-date");
            return null;
        }
        PatchsetCommand psCmd;
        if (ticket == null) {
            /*
             *  NEW TICKET
             */
            Patchset patchset = newPatchset(null, mergeBase.getName(), tipCommit.getName());
            int minLength = 10;
            int maxLength = 100;
            String minTitle = MessageFormat.format("  minimum length of a title is {0} characters.", minLength);
            String maxTitle = MessageFormat.format("  maximum length of a title is {0} characters.", maxLength);
            if (patchset.commits > 1) {
                sendError("");
                sendError("To create a proposal ticket, please squash your commits and");
                sendError("provide a meaningful commit message with a short title &");
                sendError("an optional description/body.");
                sendError("");
                sendError(minTitle);
                sendError(maxTitle);
                sendError("");
                sendRejection(cmd, "please squash to one commit");
                return null;
            }
            // require a reasonable title/subject
            String title = tipCommit.getFullMessage().trim().split("\n")[0];
            if (title.length() < minLength) {
                // reject, title too short
                sendError("");
                sendError("Please supply a longer title in your commit message!");
                sendError("");
                sendError(minTitle);
                sendError(maxTitle);
                sendError("");
                sendRejection(cmd, "ticket title is too short [{0}/{1}]", title.length(), maxLength);
                return null;
            }
            if (title.length() > maxLength) {
                // reject, title too long
                sendError("");
                sendError("Please supply a more concise title in your commit message!");
                sendError("");
                sendError(minTitle);
                sendError(maxTitle);
                sendError("");
                sendRejection(cmd, "ticket title is too long [{0}/{1}]", title.length(), maxLength);
                return null;
            }
            // assign new id
            long ticketId = ticketService.assignNewId(repository);
            // create the patchset command
            psCmd = new PatchsetCommand(user.username, patchset);
            psCmd.newTicket(tipCommit, forBranch, ticketId, cmd.getRefName());
        } else {
            /*
             *  EXISTING TICKET
             */
            Patchset patchset = newPatchset(ticket, mergeBase.getName(), tipCommit.getName());
            psCmd = new PatchsetCommand(user.username, patchset);
            psCmd.updateTicket(tipCommit, forBranch, ticket, cmd.getRefName());
        }
        // confirm user can push the patchset
        boolean pushPermitted = ticket == null
                || !ticket.hasPatchsets()
                || ticket.isAuthor(user.username)
                || ticket.isPatchsetAuthor(user.username)
                || ticket.isResponsible(user.username)
                || user.canPush(repository);
        switch (psCmd.getPatchsetType()) {
        case Proposal:
            // proposals (first patchset) are always acceptable
            break;
        case FastForward:
            // patchset updates must be permitted
            if (!pushPermitted) {
                // reject
                sendError("");
                sendError("To push a patchset to this ticket one of the following must be true:");
                sendError("  1. you created the ticket");
                sendError("  2. you created the first patchset");
                sendError("  3. you are specified as responsible for the ticket");
                sendError("  4. you are listed as a reviewer for the ticket");
                sendError("  5. you have push (RW) permission to {0}", repository.name);
                sendError("");
                sendRejection(cmd, "not permitted to push to ticket {0,number,0}", ticket.number);
                return null;
            }
            break;
        default:
            // non-fast-forward push
            if (!pushPermitted) {
                // reject
                sendRejection(cmd, "non-fast-forward ({0})", psCmd.getPatchsetType());
                return null;
            }
            break;
        }
        return psCmd;
    }
    /**
     * Creates or updates an ticket with the specified patchset.
     *
     * @param cmd
     * @return a ticket if the creation or update was successful
     */
    private TicketModel processPatchset(PatchsetCommand cmd) {
        Change change = cmd.getChange();
        if (cmd.isNewTicket()) {
            // create the ticket object
            TicketModel ticket = ticketService.createTicket(repository, cmd.getTicketId(), change);
            if (ticket != null) {
                sendInfo("");
                sendHeader("#{0,number,0}: {1}", ticket.number, StringUtils.trimString(ticket.title, Constants.LEN_SHORTLOG));
                sendInfo("created proposal ticket from patchset");
                sendInfo(ticketService.getTicketUrl(ticket));
                sendInfo("");
                // log the new patch ref
                RefLogUtils.updateRefLog(user, getRepository(),
                        Arrays.asList(new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), cmd.getRefName())));
                return ticket;
            } else {
                sendError("FAILED to create ticket");
            }
        } else {
            // update an existing ticket
            TicketModel ticket = ticketService.updateTicket(repository, cmd.getTicketId(), change);
            if (ticket != null) {
                sendInfo("");
                sendHeader("#{0,number,0}: {1}", ticket.number, StringUtils.trimString(ticket.title, Constants.LEN_SHORTLOG));
                if (change.patchset.rev == 1) {
                    // new patchset
                    sendInfo("uploaded patchset {0} ({1})", change.patchset.number, change.patchset.type.toString());
                } else {
                    // updated patchset
                    sendInfo("added {0} {1} to patchset {2}",
                            change.patchset.added,
                            change.patchset.added == 1 ? "commit" : "commits",
                            change.patchset.number);
                }
                sendInfo(ticketService.getTicketUrl(ticket));
                sendInfo("");
                // log the new patchset ref
                RefLogUtils.updateRefLog(user, getRepository(),
                    Arrays.asList(new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), cmd.getRefName())));
                // return the updated ticket
                return ticket;
            } else {
                sendError("FAILED to upload {0} for ticket {1,number,0}", change.patchset, cmd.getTicketId());
            }
        }
        return null;
    }
    /**
     * Automatically closes open tickets that have been merged to their integration
     * branch by a client.
     *
     * @param cmd
     */
    private Collection<TicketModel> processMergedTickets(ReceiveCommand cmd) {
        Map<Long, TicketModel> mergedTickets = new LinkedHashMap<Long, TicketModel>();
        final RevWalk rw = getRevWalk();
        try {
            rw.reset();
            rw.markStart(rw.parseCommit(cmd.getNewId()));
            if (!ObjectId.zeroId().equals(cmd.getOldId())) {
                rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
            }
            RevCommit c;
            while ((c = rw.next()) != null) {
                rw.parseBody(c);
                long ticketNumber = identifyTicket(c, true);
                if (ticketNumber == 0L || mergedTickets.containsKey(ticketNumber)) {
                    continue;
                }
                TicketModel ticket = ticketService.getTicket(repository, ticketNumber);
                String integrationBranch;
                if (StringUtils.isEmpty(ticket.mergeTo)) {
                    // unspecified integration branch
                    integrationBranch = null;
                } else {
                    // specified integration branch
                    integrationBranch = Constants.R_HEADS + ticket.mergeTo;
                }
                // ticket must be open and, if specified, the ref must match the integration branch
                if (ticket.isClosed() || (integrationBranch != null && !integrationBranch.equals(cmd.getRefName()))) {
                    continue;
                }
                String baseRef = PatchsetCommand.getBasePatchsetBranch(ticket.number);
                boolean knownPatchset = false;
                Set<Ref> refs = getRepository().getAllRefsByPeeledObjectId().get(c.getId());
                if (refs != null) {
                    for (Ref ref : refs) {
                        if (ref.getName().startsWith(baseRef)) {
                            knownPatchset = true;
                            break;
                        }
                    }
                }
                String mergeSha = c.getName();
                String mergeTo = Repository.shortenRefName(cmd.getRefName());
                Change change;
                Patchset patchset;
                if (knownPatchset) {
                    // identify merged patchset by the patchset tip
                    patchset = null;
                    for (Patchset ps : ticket.getPatchsets()) {
                        if (ps.tip.equals(mergeSha)) {
                            patchset = ps;
                            break;
                        }
                    }
                    if (patchset == null) {
                        // should not happen - unless ticket has been hacked
                        sendError("Failed to find the patchset for {0} in ticket {1,number,0}?!",
                                mergeSha, ticket.number);
                        continue;
                    }
                    // create a new change
                    change = new Change(user.username);
                } else {
                    // new patchset pushed by user
                    String base = cmd.getOldId().getName();
                    patchset = newPatchset(ticket, base, mergeSha);
                    PatchsetCommand psCmd = new PatchsetCommand(user.username, patchset);
                    psCmd.updateTicket(c, mergeTo, ticket, null);
                    // create a ticket patchset ref
                    updateRef(psCmd.getPatchsetBranch(), c.getId());
                    RefUpdate ru = updateRef(psCmd.getTicketBranch(), c.getId());
                    updateReflog(ru);
                    // create a change from the patchset command
                    change = psCmd.getChange();
                }
                // set the common change data about the merge
                change.setField(Field.status, Status.Merged);
                change.setField(Field.mergeSha, mergeSha);
                change.setField(Field.mergeTo, mergeTo);
                if (StringUtils.isEmpty(ticket.responsible)) {
                    // unassigned tickets are assigned to the closer
                    change.setField(Field.responsible, user.username);
                }
                ticket = ticketService.updateTicket(repository, ticket.number, change);
                if (ticket != null) {
                    sendInfo("");
                    sendHeader("#{0,number,0}: {1}", ticket.number, StringUtils.trimString(ticket.title, Constants.LEN_SHORTLOG));
                    sendInfo("closed by push of {0} to {1}", patchset, mergeTo);
                    sendInfo(ticketService.getTicketUrl(ticket));
                    sendInfo("");
                    mergedTickets.put(ticket.number, ticket);
                } else {
                    String shortid = mergeSha.substring(0, settings.getInteger(Keys.web.shortCommitIdLength, 6));
                    sendError("FAILED to close ticket {0,number,0} by push of {1}", ticketNumber, shortid);
                }
            }
        } catch (IOException e) {
            LOGGER.error("Can't scan for changes to close", e);
        } finally {
            rw.reset();
        }
        return mergedTickets.values();
    }
    /**
     * Try to identify a ticket id from the commit.
     *
     * @param commit
     * @param parseMessage
     * @return a ticket id or 0
     */
    private long identifyTicket(RevCommit commit, boolean parseMessage) {
        // try lookup by change ref
        Map<AnyObjectId, Set<Ref>> map = getRepository().getAllRefsByPeeledObjectId();
        Set<Ref> refs = map.get(commit.getId());
        if (!ArrayUtils.isEmpty(refs)) {
            for (Ref ref : refs) {
                long number = PatchsetCommand.getTicketNumber(ref.getName());
                if (number > 0) {
                    return number;
                }
            }
        }
        if (parseMessage) {
            // parse commit message looking for fixes/closes #n
            Pattern p = Pattern.compile("(?:fixes|closes)[\\s-]+#?(\\d+)", Pattern.CASE_INSENSITIVE);
            Matcher m = p.matcher(commit.getFullMessage());
            while (m.find()) {
                String val = m.group();
                return Long.parseLong(val);
            }
        }
        return 0L;
    }
    private int countCommits(String baseId, String tipId) {
        int count = 0;
        RevWalk walk = getRevWalk();
        walk.reset();
        walk.sort(RevSort.TOPO);
        walk.sort(RevSort.REVERSE, true);
        try {
            RevCommit tip = walk.parseCommit(getRepository().resolve(tipId));
            RevCommit base = walk.parseCommit(getRepository().resolve(baseId));
            walk.markStart(tip);
            walk.markUninteresting(base);
            for (;;) {
                RevCommit c = walk.next();
                if (c == null) {
                    break;
                }
                count++;
            }
        } catch (IOException e) {
            // Should never happen, the core receive process would have
            // identified the missing object earlier before we got control.
            LOGGER.error("failed to get commit count", e);
            return 0;
        } finally {
            walk.release();
        }
        return count;
    }
    /**
     * Creates a new patchset with metadata.
     *
     * @param ticket
     * @param mergeBase
     * @param tip
     */
    private Patchset newPatchset(TicketModel ticket, String mergeBase, String tip) {
        int totalCommits = countCommits(mergeBase, tip);
        Patchset newPatchset = new Patchset();
        newPatchset.tip = tip;
        newPatchset.base = mergeBase;
        newPatchset.commits = totalCommits;
        Patchset currPatchset = ticket == null ? null : ticket.getCurrentPatchset();
        if (currPatchset == null) {
            /*
             * PROPOSAL PATCHSET
             * patchset 1, rev 1
             */
            newPatchset.number = 1;
            newPatchset.rev = 1;
            newPatchset.type = PatchsetType.Proposal;
            // diffstat from merge base
            DiffStat diffStat = DiffUtils.getDiffStat(getRepository(), mergeBase, tip);
            newPatchset.insertions = diffStat.getInsertions();
            newPatchset.deletions = diffStat.getDeletions();
        } else {
            /*
             * PATCHSET UPDATE
             */
            int added = totalCommits - currPatchset.commits;
            boolean ff = JGitUtils.isMergedInto(getRepository(), currPatchset.tip, tip);
            boolean squash = added < 0;
            boolean rebase = !currPatchset.base.equals(mergeBase);
            // determine type, number and rev of the patchset
            if (ff) {
                /*
                 * FAST-FORWARD
                 * patchset number preserved, rev incremented
                 */
                boolean merged = JGitUtils.isMergedInto(getRepository(), currPatchset.tip, ticket.mergeTo);
                if (merged) {
                    // current patchset was already merged
                    // new patchset, mark as rebase
                    newPatchset.type = PatchsetType.Rebase;
                    newPatchset.number = currPatchset.number + 1;
                    newPatchset.rev = 1;
                    // diffstat from parent
                    DiffStat diffStat = DiffUtils.getDiffStat(getRepository(), mergeBase, tip);
                    newPatchset.insertions = diffStat.getInsertions();
                    newPatchset.deletions = diffStat.getDeletions();
                } else {
                    // FF update to patchset
                    newPatchset.type = PatchsetType.FastForward;
                    newPatchset.number = currPatchset.number;
                    newPatchset.rev = currPatchset.rev + 1;
                    newPatchset.parent = currPatchset.tip;
                    // diffstat from parent
                    DiffStat diffStat = DiffUtils.getDiffStat(getRepository(), currPatchset.tip, tip);
                    newPatchset.insertions = diffStat.getInsertions();
                    newPatchset.deletions = diffStat.getDeletions();
                }
            } else {
                /*
                 * NON-FAST-FORWARD
                 * new patchset, rev 1
                 */
                if (rebase && squash) {
                    newPatchset.type = PatchsetType.Rebase_Squash;
                    newPatchset.number = currPatchset.number + 1;
                    newPatchset.rev = 1;
                } else if (squash) {
                    newPatchset.type = PatchsetType.Squash;
                    newPatchset.number = currPatchset.number + 1;
                    newPatchset.rev = 1;
                } else if (rebase) {
                    newPatchset.type = PatchsetType.Rebase;
                    newPatchset.number = currPatchset.number + 1;
                    newPatchset.rev = 1;
                } else {
                    newPatchset.type = PatchsetType.Amend;
                    newPatchset.number = currPatchset.number + 1;
                    newPatchset.rev = 1;
                }
                // diffstat from merge base
                DiffStat diffStat = DiffUtils.getDiffStat(getRepository(), mergeBase, tip);
                newPatchset.insertions = diffStat.getInsertions();
                newPatchset.deletions = diffStat.getDeletions();
            }
            if (added > 0) {
                // ignore squash (negative add)
                newPatchset.added = added;
            }
        }
        return newPatchset;
    }
    private RefUpdate updateRef(String ref, ObjectId newId) {
        ObjectId ticketRefId = ObjectId.zeroId();
        try {
            ticketRefId = getRepository().resolve(ref);
        } catch (Exception e) {
            // ignore
        }
        try {
            RefUpdate ru = getRepository().updateRef(ref,  false);
            ru.setRefLogIdent(getRefLogIdent());
            ru.setForceUpdate(true);
            ru.setExpectedOldObjectId(ticketRefId);
            ru.setNewObjectId(newId);
            RefUpdate.Result result = ru.update(getRevWalk());
            if (result == RefUpdate.Result.LOCK_FAILURE) {
                sendError("Failed to obtain lock when updating {0}:{1}", repository.name, ref);
                sendError("Perhaps an administrator should remove {0}/{1}.lock?", getRepository().getDirectory(), ref);
                return null;
            }
            return ru;
        } catch (IOException e) {
            LOGGER.error("failed to update ref " + ref, e);
            sendError("There was an error updating ref {0}:{1}", repository.name, ref);
        }
        return null;
    }
    private void updateReflog(RefUpdate ru) {
        if (ru == null) {
            return;
        }
        ReceiveCommand.Type type = null;
        switch (ru.getResult()) {
        case NEW:
            type = Type.CREATE;
            break;
        case FAST_FORWARD:
            type = Type.UPDATE;
            break;
        case FORCED:
            type = Type.UPDATE_NONFASTFORWARD;
            break;
        default:
            LOGGER.error(MessageFormat.format("unexpected ref update type {0} for {1}",
                    ru.getResult(), ru.getName()));
            return;
        }
        ReceiveCommand cmd = new ReceiveCommand(ru.getOldObjectId(), ru.getNewObjectId(), ru.getName(), type);
        RefLogUtils.updateRefLog(user, getRepository(), Arrays.asList(cmd));
    }
    /**
     * Merge the specified patchset to the integration branch.
     *
     * @param ticket
     * @param patchset
     * @return true, if successful
     */
    public MergeStatus merge(TicketModel ticket) {
        PersonIdent committer = new PersonIdent(user.getDisplayName(), StringUtils.isEmpty(user.emailAddress) ? (user.username + "@gitblit") : user.emailAddress);
        Patchset patchset = ticket.getCurrentPatchset();
        String message = MessageFormat.format("Merged #{0,number,0} \"{1}\"", ticket.number, ticket.title);
        Ref oldRef = null;
        try {
            oldRef = getRepository().getRef(ticket.mergeTo);
        } catch (IOException e) {
            LOGGER.error("failed to get ref for " + ticket.mergeTo, e);
        }
        MergeResult mergeResult = JGitUtils.merge(
                getRepository(),
                patchset.tip,
                ticket.mergeTo,
                committer,
                message);
        if (StringUtils.isEmpty(mergeResult.sha)) {
            LOGGER.error("FAILED to merge {} to {} ({})", new Object [] { patchset, ticket.mergeTo, mergeResult.status.name() });
            return mergeResult.status;
        }
        Change change = new Change(user.username);
        change.setField(Field.status, Status.Merged);
        change.setField(Field.mergeSha, mergeResult.sha);
        change.setField(Field.mergeTo, ticket.mergeTo);
        if (StringUtils.isEmpty(ticket.responsible)) {
            // unassigned tickets are assigned to the closer
            change.setField(Field.responsible, user.username);
        }
        long ticketId = ticket.number;
        ticket = ticketService.updateTicket(repository, ticket.number, change);
        if (ticket != null) {
            ticketNotifier.queueMailing(ticket);
            // update the reflog with the merge
            if (oldRef != null) {
                ReceiveCommand cmd = new ReceiveCommand(oldRef.getObjectId(),
                        ObjectId.fromString(mergeResult.sha), oldRef.getName());
                RefLogUtils.updateRefLog(user, getRepository(), Arrays.asList(cmd));
            }
            return mergeResult.status;
        } else {
            LOGGER.error("FAILED to resolve ticket {} by merge from web ui", ticketId);
        }
        return mergeResult.status;
    }
    public void sendAll() {
        ticketNotifier.sendAll();
    }
}
src/main/java/com/gitblit/manager/GitblitManager.java
@@ -62,6 +62,7 @@
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 @@
        }
    }
    /**
     * 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
     *
src/main/java/com/gitblit/manager/IGitblit.java
@@ -26,6 +26,7 @@
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 @@
     */
    Collection<GitClientApplication> getClientApplications();
    /**
     * Returns the ticket service.
     *
     * @return a ticket service
     */
    ITicketService getTicketService();
}
src/main/java/com/gitblit/manager/RepositoryManager.java
@@ -801,6 +801,9 @@
            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 @@
        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"))) {
src/main/java/com/gitblit/models/RepositoryModel.java
@@ -85,6 +85,9 @@
    public int maxActivityCommits;
    public List<String> metricAuthorExclusions;
    public CommitMessageRenderer commitMessageRenderer;
    public boolean acceptNewPatchsets;
    public boolean acceptNewTickets;
    public boolean requireApproval;
    public transient boolean isCollectingGarbage;
    public Date lastGC;
@@ -105,6 +108,8 @@
        this.projectPath = StringUtils.getFirstPathElement(name);
        this.owners = new ArrayList<String>();
        this.isBare = true;
        this.acceptNewTickets = true;
        this.acceptNewPatchsets = true;
        addOwner(owner);
    }
@@ -138,6 +143,10 @@
    public void resetDisplayName() {
        displayName = null;
    }
    public String getRID() {
        return StringUtils.getSHA1(name);
    }
    @Override
@@ -209,6 +218,8 @@
        clone.federationStrategy = federationStrategy;
        clone.showRemoteBranches = false;
        clone.allowForks = false;
        clone.acceptNewPatchsets = false;
        clone.acceptNewTickets = false;
        clone.skipSizeCalculation = skipSizeCalculation;
        clone.skipSummaryMetrics = skipSummaryMetrics;
        clone.sparkleshareId = sparkleshareId;
src/main/java/com/gitblit/models/TicketModel.java
New file
@@ -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;
        }
    }
}
src/main/java/com/gitblit/models/UserModel.java
@@ -446,6 +446,18 @@
        return canAdmin() || model.isUsersPersonalRepository(username) || model.isOwner(username);
    }
    public boolean canReviewPatchset(RepositoryModel model) {
        return isAuthenticated && canClone(model);
    }
    public boolean canApprovePatchset(RepositoryModel model) {
        return isAuthenticated && canPush(model);
    }
    public boolean canVetoPatchset(RepositoryModel model) {
        return isAuthenticated && canPush(model);
    }
    /**
     * This returns true if the user has fork privileges or the user has fork
     * privileges because of a team membership.
src/main/java/com/gitblit/servlet/PtServlet.java
New file
@@ -0,0 +1,201 @@
/*
 * 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.servlet;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
import org.apache.commons.compress.compressors.CompressorOutputStream;
import org.apache.commons.compress.compressors.CompressorStreamFactory;
import org.apache.wicket.util.io.ByteArrayOutputStream;
import org.eclipse.jgit.lib.FileMode;
import com.gitblit.dagger.DaggerServlet;
import com.gitblit.manager.IRuntimeManager;
import dagger.ObjectGraph;
/**
 * Handles requests for the Barnum pt (patchset tool).
 *
 * The user-agent determines the content and compression format.
 *
 * @author James Moger
 *
 */
public class PtServlet extends DaggerServlet {
    private static final long serialVersionUID = 1L;
    private static final long lastModified = System.currentTimeMillis();
    private IRuntimeManager runtimeManager;
    @Override
    protected void inject(ObjectGraph dagger) {
        this.runtimeManager = dagger.get(IRuntimeManager.class);
    }
    @Override
    protected long getLastModified(HttpServletRequest req) {
        File file = runtimeManager.getFileOrFolder("tickets.pt", "${baseFolder}/pt.py");
        if (file.exists()) {
            return Math.max(lastModified, file.lastModified());
        } else {
            return lastModified;
        }
    }
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        try {
            response.setContentType("application/octet-stream");
            response.setDateHeader("Last-Modified", lastModified);
            response.setHeader("Cache-Control", "none");
            response.setHeader("Pragma", "no-cache");
            response.setDateHeader("Expires", 0);
            boolean windows = false;
            try {
                String useragent = request.getHeader("user-agent").toString();
                windows = useragent.toLowerCase().contains("windows");
            } catch (Exception e) {
            }
            byte[] pyBytes;
            File file = runtimeManager.getFileOrFolder("tickets.pt", "${baseFolder}/pt.py");
            if (file.exists()) {
                // custom script
                pyBytes = readAll(new FileInputStream(file));
            } else {
                // default script
                pyBytes = readAll(getClass().getResourceAsStream("/pt.py"));
            }
            if (windows) {
                // windows: download zip file with pt.py and pt.cmd
                response.setHeader("Content-Disposition", "attachment; filename=\"pt.zip\"");
                OutputStream os = response.getOutputStream();
                ZipArchiveOutputStream zos = new ZipArchiveOutputStream(os);
                // add the Python script
                ZipArchiveEntry pyEntry = new ZipArchiveEntry("pt.py");
                pyEntry.setSize(pyBytes.length);
                pyEntry.setUnixMode(FileMode.EXECUTABLE_FILE.getBits());
                pyEntry.setTime(lastModified);
                zos.putArchiveEntry(pyEntry);
                zos.write(pyBytes);
                zos.closeArchiveEntry();
                // add a Python launch cmd file
                byte [] cmdBytes = readAll(getClass().getResourceAsStream("/pt.cmd"));
                ZipArchiveEntry cmdEntry = new ZipArchiveEntry("pt.cmd");
                cmdEntry.setSize(cmdBytes.length);
                cmdEntry.setUnixMode(FileMode.REGULAR_FILE.getBits());
                cmdEntry.setTime(lastModified);
                zos.putArchiveEntry(cmdEntry);
                zos.write(cmdBytes);
                zos.closeArchiveEntry();
                // add a brief readme
                byte [] txtBytes = readAll(getClass().getResourceAsStream("/pt.txt"));
                ZipArchiveEntry txtEntry = new ZipArchiveEntry("readme.txt");
                txtEntry.setSize(txtBytes.length);
                txtEntry.setUnixMode(FileMode.REGULAR_FILE.getBits());
                txtEntry.setTime(lastModified);
                zos.putArchiveEntry(txtEntry);
                zos.write(txtBytes);
                zos.closeArchiveEntry();
                // cleanup
                zos.finish();
                zos.close();
                os.flush();
            } else {
                // unix: download a tar.gz file with pt.py set with execute permissions
                response.setHeader("Content-Disposition", "attachment; filename=\"pt.tar.gz\"");
                OutputStream os = response.getOutputStream();
                CompressorOutputStream cos = new CompressorStreamFactory().createCompressorOutputStream(CompressorStreamFactory.GZIP, os);
                TarArchiveOutputStream tos = new TarArchiveOutputStream(cos);
                tos.setAddPaxHeadersForNonAsciiNames(true);
                tos.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX);
                // add the Python script
                TarArchiveEntry pyEntry = new TarArchiveEntry("pt");
                pyEntry.setMode(FileMode.EXECUTABLE_FILE.getBits());
                pyEntry.setModTime(lastModified);
                pyEntry.setSize(pyBytes.length);
                tos.putArchiveEntry(pyEntry);
                tos.write(pyBytes);
                tos.closeArchiveEntry();
                // add a brief readme
                byte [] txtBytes = readAll(getClass().getResourceAsStream("/pt.txt"));
                TarArchiveEntry txtEntry = new TarArchiveEntry("README");
                txtEntry.setMode(FileMode.REGULAR_FILE.getBits());
                txtEntry.setModTime(lastModified);
                txtEntry.setSize(txtBytes.length);
                tos.putArchiveEntry(txtEntry);
                tos.write(txtBytes);
                tos.closeArchiveEntry();
                // cleanup
                tos.finish();
                tos.close();
                cos.close();
                os.flush();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    byte [] readAll(InputStream is) {
        ByteArrayOutputStream os = new ByteArrayOutputStream();
        try {
            byte [] buffer = new byte[4096];
            int len = 0;
            while ((len = is.read(buffer)) > -1) {
                os.write(buffer, 0, len);
            }
            return os.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                os.close();
                is.close();
            } catch (Exception e) {
                // ignore
            }
        }
        return new byte[0];
    }
}
src/main/java/com/gitblit/tickets/BranchTicketService.java
New file
@@ -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();
    }
}
src/main/java/com/gitblit/tickets/FileTicketService.java
New file
@@ -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();
    }
}
src/main/java/com/gitblit/tickets/ITicketService.java
New file
@@ -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();
    }
}
src/main/java/com/gitblit/tickets/NullTicketService.java
New file
@@ -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();
    }
}
src/main/java/com/gitblit/tickets/QueryBuilder.java
New file
@@ -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();
    }
}
src/main/java/com/gitblit/tickets/QueryResult.java
New file
@@ -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;
    }
}
src/main/java/com/gitblit/tickets/RedisTicketService.java
New file
@@ -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) + ")";
    }
}
src/main/java/com/gitblit/tickets/TicketIndexer.java
New file
@@ -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;
    }
}
src/main/java/com/gitblit/tickets/TicketLabel.java
New file
@@ -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;
    }
}
src/main/java/com/gitblit/tickets/TicketMilestone.java
New file
@@ -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;
    }
}
src/main/java/com/gitblit/tickets/TicketNotifier.java
New file
@@ -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();
    }
}
src/main/java/com/gitblit/tickets/TicketResponsible.java
New file
@@ -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());
    }
}
src/main/java/com/gitblit/tickets/TicketSerializer.java
New file
@@ -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);
            }
        }
    }
}
src/main/java/com/gitblit/tickets/commands.md
New file
@@ -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
src/main/java/com/gitblit/tickets/email.css
New file
@@ -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;
}
src/main/java/com/gitblit/utils/JGitUtils.java
@@ -59,6 +59,8 @@
import org.eclipse.jgit.lib.RepositoryCache.FileKey;
import org.eclipse.jgit.lib.StoredConfig;
import org.eclipse.jgit.lib.TreeFormatter;
import org.eclipse.jgit.merge.MergeStrategy;
import org.eclipse.jgit.merge.RecursiveMerger;
import org.eclipse.jgit.revwalk.RevBlob;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevObject;
@@ -82,6 +84,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.gitblit.GitBlitException;
import com.gitblit.models.GitNote;
import com.gitblit.models.PathModel;
import com.gitblit.models.PathModel.PathChangeModel;
@@ -2145,4 +2148,208 @@
        }
        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, 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);
    }
}
src/main/java/com/gitblit/utils/JsonUtils.java
@@ -274,10 +274,10 @@
        return builder.create();
    }
    private static class GmtDateTypeAdapter implements JsonSerializer<Date>, JsonDeserializer<Date> {
    public static class GmtDateTypeAdapter implements JsonSerializer<Date>, JsonDeserializer<Date> {
        private final DateFormat dateFormat;
        private GmtDateTypeAdapter() {
        public GmtDateTypeAdapter() {
            dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US);
            dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
        }
src/main/java/com/gitblit/utils/MarkdownUtils.java
@@ -132,6 +132,10 @@
        String mentionReplacement = String.format(" **<a href=\"%1s/user/$1\">@$1</a>**", canonicalUrl);
        text = text.replaceAll("\\s@([A-Za-z0-9-_]+)", mentionReplacement);
        // 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
        int shaLen = settings.getInteger(Keys.web.shortCommitIdLength, 6);
        String commitPattern = MessageFormat.format("\\s([A-Fa-f0-9]'{'{0}'}')([A-Fa-f0-9]'{'{1}'}')", shaLen, 40 - shaLen);
src/main/java/com/gitblit/utils/RefLogUtils.java
@@ -213,6 +213,22 @@
     */
    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 @@
            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 @@
                        // 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) {
                    }
                }
            }
src/main/java/com/gitblit/wicket/GitBlitWebApp.java
@@ -38,6 +38,7 @@
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.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.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.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;
@@ -167,6 +172,12 @@
        mount("/blame", BlamePage.class, "r", "h", "f");
        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");
@@ -285,6 +296,10 @@
        return gitblit;
    }
    public ITicketService tickets() {
        return gitblit.getTicketService();
    }
    public TimeZone getTimezone() {
        return runtimeManager.getTimezone();
    }
src/main/java/com/gitblit/wicket/GitBlitWebApp.properties
@@ -18,7 +18,7 @@
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.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
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}
src/main/java/com/gitblit/wicket/pages/BasePage.java
@@ -15,6 +15,8 @@
 */
package com.gitblit.wicket.pages;
import java.io.IOException;
import java.io.InputStream;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Calendar;
@@ -31,6 +33,7 @@
import javax.servlet.http.HttpServletRequest;
import org.apache.commons.io.IOUtils;
import org.apache.wicket.Application;
import org.apache.wicket.Page;
import org.apache.wicket.PageParameters;
@@ -460,4 +463,26 @@
        }
        error(message, true);
    }
    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();
    }
}
src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.html
@@ -34,15 +34,18 @@
                <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>
                <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>
                <tr><th colspan="2"><hr/></th></tr>
                <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>
                <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>
                <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>
                <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>
                <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>
                <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>
                <tr><th><wicket:message key="gb.commitMessageRenderer"></wicket:message></th><td class="edit"><select class="span2" wicket:id="commitMessageRenderer" tabindex="13" /></td></tr>
                <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>
                <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>
                <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>
                <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>
                <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>
                <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>
                <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>
                <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>
                <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>
                <tr><th><wicket:message key="gb.commitMessageRenderer"></wicket:message></th><td class="edit"><select class="span2" wicket:id="commitMessageRenderer" tabindex="16" /></td></tr>
                <tr><th colspan="2"><hr/></th></tr>
                <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>
                <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>
            </tbody>
        </table>
        </div>
@@ -51,15 +54,15 @@
        <div class="tab-pane" id="permissions">
            <table class="plain">
                <tbody class="settings">
                    <tr><th><wicket:message key="gb.owners"></wicket:message></th><td class="edit"><span wicket:id="owners" tabindex="15" /> </td></tr>
                    <tr><th><wicket:message key="gb.owners"></wicket:message></th><td class="edit"><span wicket:id="owners" tabindex="18" /> </td></tr>
                    <tr><th colspan="2"><hr/></th></tr>
                    <tr><th><wicket:message key="gb.accessRestriction"></wicket:message></th><td class="edit"><select class="span4" wicket:id="accessRestriction" tabindex="16" /></td></tr>
                    <tr><th><wicket:message key="gb.accessRestriction"></wicket:message></th><td class="edit"><select class="span4" wicket:id="accessRestriction" tabindex="19" /></td></tr>
                    <tr><th colspan="2"><hr/></th></tr>
                    <tr><th><wicket:message key="gb.authorizationControl"></wicket:message></th><td style="padding:2px;"><span class="authorizationControl" wicket:id="authorizationControl"></span></td></tr>
                    <tr><th colspan="2"><hr/></th></tr>
                    <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>
                    <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>
                    <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>
                    <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>
                    <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>
                    <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>
                    <tr><th colspan="2"><hr/></th></tr>
                    <tr><th><wicket:message key="gb.userPermissions"></wicket:message></th><td style="padding:2px;"><span wicket:id="users"></span></td></tr>
                    <tr><th colspan="2"><hr/></th></tr>
@@ -72,7 +75,7 @@
        <div class="tab-pane" id="federation">
            <table class="plain">
                <tbody class="settings">
                    <tr><th><wicket:message key="gb.federationStrategy"></wicket:message></th><td class="edit"><select class="span4" wicket:id="federationStrategy" tabindex="20" /></td></tr>
                    <tr><th><wicket:message key="gb.federationStrategy"></wicket:message></th><td class="edit"><select class="span4" wicket:id="federationStrategy" tabindex="23" /></td></tr>
                    <tr><th><wicket:message key="gb.federationSets"></wicket:message></th><td style="padding:2px;"><span wicket:id="federationSets"></span></td></tr>
                </tbody>
            </table>
src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.java
@@ -410,12 +410,12 @@
                    }
                    // save the repository
                    app().repositories().updateRepositoryModel(oldName, repositoryModel, isCreate);
                    app().gitblit().updateRepositoryModel(oldName, repositoryModel, isCreate);
                    // repository access permissions
                    if (repositoryModel.accessRestriction.exceeds(AccessRestrictionType.NONE)) {
                        app().repositories().setUserAccessPermissions(repositoryModel, repositoryUsers);
                        app().repositories().setTeamAccessPermissions(repositoryModel, repositoryTeams);
                        app().gitblit().setUserAccessPermissions(repositoryModel, repositoryUsers);
                        app().gitblit().setTeamAccessPermissions(repositoryModel, repositoryTeams);
                    }
                } catch (GitBlitException e) {
                    error(e.getMessage());
@@ -466,11 +466,14 @@
        }
        form.add(new DropDownChoice<FederationStrategy>("federationStrategy", federationStrategies,
                new FederationTypeRenderer()));
        form.add(new CheckBox("acceptNewPatchsets"));
        form.add(new CheckBox("acceptNewTickets"));
        form.add(new CheckBox("requireApproval"));
        form.add(new CheckBox("useIncrementalPushTags"));
        form.add(new CheckBox("showRemoteBranches"));
        form.add(new CheckBox("skipSizeCalculation"));
        form.add(new CheckBox("skipSummaryMetrics"));
        List<Integer> maxActivityCommits  = Arrays.asList(-1, 0, 25, 50, 75, 100, 150, 200, 250, 500 );
        List<Integer> maxActivityCommits  = Arrays.asList(-1, 0, 25, 50, 75, 100, 150, 200, 250, 500);
        form.add(new DropDownChoice<Integer>("maxActivityCommits", maxActivityCommits, new MaxActivityCommitsRenderer()));
        metricAuthorExclusions = new Model<String>(ArrayUtils.isEmpty(repositoryModel.metricAuthorExclusions) ? ""
src/main/java/com/gitblit/wicket/pages/EditTicketPage.html
New file
@@ -0,0 +1,66 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:wicket="http://wicket.apache.org/dtds.data/wicket-xhtml1.3-strict.dtd"
      xml:lang="en"
      lang="en">
<wicket:extend>
<body onload="document.getElementById('title').focus();">
<div class="container">
    <!-- page header -->
    <div class="title" style="font-size: 22px; color: rgb(0, 32, 96);padding: 3px 0px 7px;">
        <span class="project"><wicket:message key="gb.editTicket"></wicket:message></span>
    </div>
    <form style="padding-top:5px;" wicket:id="editForm">
    <div class="row">
    <div class="span12">
        <!-- Edit Ticket Table -->
        <table class="ticket">
            <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>
            <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>
            <tr><th style="vertical-align: top;"><wicket:message key="gb.description"></wicket:message></th><td class="edit">
                <div style="background-color:#fbfbfb;border:1px solid #ccc;">
                <ul class="nav nav-pills" style="margin: 2px 5px !important">
                    <li class="active"><a tabindex="-1" href="#edit" data-toggle="tab"><wicket:message key="gb.edit">[edit]</wicket:message></a></li>
                    <li><a tabindex="-1" 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="edit">
                        <textarea class="input-xxlarge ticket-text-editor" wicket:id="description"></textarea>
                    </div>
                    <div class="tab-pane" id="preview">
                        <div class="preview ticket-text-editor">
                            <div class="markdown input-xxlarge" wicket:id="descriptionPreview"></div>
                        </div>
                    </div>
                </div>
                </div>
            </td></tr>
            <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>
            <tr wicket:id="responsible"></tr>
            <tr wicket:id="milestone"></tr>
        </table>
    </div>
    </div>
    <div class="row">
    <div class="span12">
        <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>
    </div>
    </div>
    </form>
</div>
</body>
<wicket:fragment wicket:id="responsibleFragment">
    <th><wicket:message key="gb.responsible"></wicket:message></th><td class="edit"><select class="input-large" wicket:id="responsible"></select></td>
</wicket:fragment>
<wicket:fragment wicket:id="milestoneFragment">
    <th><wicket:message key="gb.milestone"></wicket:message></th><td class="edit"><select class="input-large" wicket:id="milestone"></select></td>
</wicket:fragment>
</wicket:extend>
</html>
src/main/java/com/gitblit/wicket/pages/EditTicketPage.java
New file
@@ -0,0 +1,290 @@
/*
 * 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.wicket.pages;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import org.apache.wicket.PageParameters;
import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.form.Button;
import org.apache.wicket.markup.html.form.DropDownChoice;
import org.apache.wicket.markup.html.form.Form;
import org.apache.wicket.markup.html.form.TextField;
import org.apache.wicket.markup.html.panel.Fragment;
import org.apache.wicket.model.IModel;
import org.apache.wicket.model.Model;
import com.gitblit.Constants.AccessPermission;
import com.gitblit.models.RegistrantAccessPermission;
import com.gitblit.models.TicketModel;
import com.gitblit.models.TicketModel.Change;
import com.gitblit.models.TicketModel.Field;
import com.gitblit.models.TicketModel.Status;
import com.gitblit.models.TicketModel.Type;
import com.gitblit.models.UserModel;
import com.gitblit.tickets.TicketMilestone;
import com.gitblit.tickets.TicketNotifier;
import com.gitblit.tickets.TicketResponsible;
import com.gitblit.utils.StringUtils;
import com.gitblit.wicket.GitBlitWebSession;
import com.gitblit.wicket.WicketUtils;
import com.gitblit.wicket.panels.MarkdownTextArea;
/**
 * Page for editing a ticket.
 *
 * @author James Moger
 *
 */
public class EditTicketPage extends RepositoryPage {
    static final String NIL = "<nil>";
    static final String ESC_NIL = StringUtils.escapeForHtml(NIL,  false);
    private IModel<TicketModel.Type> typeModel;
    private IModel<String> titleModel;
    private MarkdownTextArea descriptionEditor;
    private IModel<String> topicModel;
    private IModel<TicketResponsible> responsibleModel;
    private IModel<TicketMilestone> milestoneModel;
    private Label descriptionPreview;
    public EditTicketPage(PageParameters params) {
        super(params);
        UserModel currentUser = GitBlitWebSession.get().getUser();
        if (currentUser == null) {
            currentUser = UserModel.ANONYMOUS;
        }
        if (!currentUser.isAuthenticated || !app().tickets().isAcceptingTicketUpdates(getRepositoryModel())) {
            // tickets prohibited
            setResponsePage(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
        }
        long ticketId = 0L;
        try {
            String h = WicketUtils.getObject(params);
            ticketId = Long.parseLong(h);
        } catch (Exception e) {
            setResponsePage(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
        }
        TicketModel ticket = app().tickets().getTicket(getRepositoryModel(), ticketId);
        if (ticket == null) {
            setResponsePage(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
        }
        typeModel = Model.of(ticket.type);
        titleModel = Model.of(ticket.title);
        topicModel = Model.of(ticket.topic == null ? "" : ticket.topic);
        responsibleModel = Model.of();
        milestoneModel = Model.of();
        setStatelessHint(false);
        setOutputMarkupId(true);
        Form<Void> form = new Form<Void>("editForm") {
            private static final long serialVersionUID = 1L;
            @Override
            protected void onSubmit() {
                long ticketId = 0L;
                try {
                    String h = WicketUtils.getObject(getPageParameters());
                    ticketId = Long.parseLong(h);
                } catch (Exception e) {
                    setResponsePage(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
                }
                TicketModel ticket = app().tickets().getTicket(getRepositoryModel(), ticketId);
                String createdBy = GitBlitWebSession.get().getUsername();
                Change change = new Change(createdBy);
                String title = titleModel.getObject();
                if (!ticket.title.equals(title)) {
                    // title change
                    change.setField(Field.title, title);
                }
                String description = descriptionEditor.getText();
                if (!ticket.body.equals(description)) {
                    // description change
                    change.setField(Field.body, description);
                }
                Type type = typeModel.getObject();
                if (!ticket.type.equals(type)) {
                    // type change
                    change.setField(Field.type, type);
                }
                String topic = topicModel.getObject();
                if ((StringUtils.isEmpty(ticket.topic) && !StringUtils.isEmpty(topic))
                        || (!StringUtils.isEmpty(topic) && !topic.equals(ticket.topic))) {
                    // topic change
                    change.setField(Field.topic, topic);
                }
                TicketResponsible responsible = responsibleModel == null ? null : responsibleModel.getObject();
                if (responsible != null && !responsible.username.equals(ticket.responsible)) {
                    // responsible change
                    change.setField(Field.responsible, responsible.username);
                    if (!StringUtils.isEmpty(responsible.username)) {
                        if (!ticket.isWatching(responsible.username)) {
                            change.watch(responsible.username);
                        }
                    }
                }
                TicketMilestone milestone = milestoneModel == null ? null : milestoneModel.getObject();
                if (milestone != null && !milestone.name.equals(ticket.milestone)) {
                    // milestone change
                    if (NIL.equals(milestone.name)) {
                        change.setField(Field.milestone, "");
                    } else {
                        change.setField(Field.milestone, milestone.name);
                    }
                }
                if (change.hasFieldChanges()) {
                    if (!ticket.isWatching(createdBy)) {
                        change.watch(createdBy);
                    }
                    ticket = app().tickets().updateTicket(getRepositoryModel(), ticket.number, change);
                    if (ticket != null) {
                        TicketNotifier notifier = app().tickets().createNotifier();
                        notifier.sendMailing(ticket);
                        setResponsePage(TicketsPage.class, WicketUtils.newObjectParameter(getRepositoryModel().name, "" + ticket.number));
                    } else {
                        // TODO error
                    }
                } else {
                    // nothing to change?!
                    setResponsePage(TicketsPage.class, WicketUtils.newObjectParameter(getRepositoryModel().name, "" + ticket.number));
                }
            }
        };
        add(form);
        List<Type> typeChoices;
        if (ticket.isProposal()) {
            typeChoices = Arrays.asList(Type.Proposal);
        } else {
            typeChoices = Arrays.asList(TicketModel.Type.choices());
        }
        form.add(new DropDownChoice<TicketModel.Type>("type", typeModel, typeChoices));
        form.add(new TextField<String>("title", titleModel));
        form.add(new TextField<String>("topic", topicModel));
        final IModel<String> markdownPreviewModel = new Model<String>();
        descriptionPreview = new Label("descriptionPreview", markdownPreviewModel);
        descriptionPreview.setEscapeModelStrings(false);
        descriptionPreview.setOutputMarkupId(true);
        form.add(descriptionPreview);
        descriptionEditor = new MarkdownTextArea("description", markdownPreviewModel, descriptionPreview);
        descriptionEditor.setRepository(repositoryName);
        descriptionEditor.setText(ticket.body);
        form.add(descriptionEditor);
        if (currentUser != null && currentUser.isAuthenticated && currentUser.canPush(getRepositoryModel())) {
            // responsible
            Set<String> userlist = new TreeSet<String>(ticket.getParticipants());
            for (RegistrantAccessPermission rp : app().repositories().getUserAccessPermissions(getRepositoryModel())) {
                if (rp.permission.atLeast(AccessPermission.PUSH) && !rp.isTeam()) {
                    userlist.add(rp.registrant);
                }
            }
            List<TicketResponsible> responsibles = new ArrayList<TicketResponsible>();
            for (String username : userlist) {
                UserModel user = app().users().getUserModel(username);
                if (user != null) {
                    TicketResponsible responsible = new TicketResponsible(user);
                    responsibles.add(responsible);
                    if (user.username.equals(ticket.responsible)) {
                        responsibleModel.setObject(responsible);
                    }
                }
            }
            Collections.sort(responsibles);
            responsibles.add(new TicketResponsible(NIL, "", ""));
            Fragment responsible = new Fragment("responsible", "responsibleFragment", this);
            responsible.add(new DropDownChoice<TicketResponsible>("responsible", responsibleModel, responsibles));
            form.add(responsible.setVisible(!responsibles.isEmpty()));
            // milestone
            List<TicketMilestone> milestones = app().tickets().getMilestones(getRepositoryModel(), Status.Open);
            for (TicketMilestone milestone : milestones) {
                if (milestone.name.equals(ticket.milestone)) {
                    milestoneModel.setObject(milestone);
                    break;
                }
            }
            if (!milestones.isEmpty()) {
                milestones.add(new TicketMilestone(NIL));
            }
            Fragment milestone = new Fragment("milestone", "milestoneFragment", this);
            milestone.add(new DropDownChoice<TicketMilestone>("milestone", milestoneModel, milestones));
            form.add(milestone.setVisible(!milestones.isEmpty()));
        } else {
            // user does not have permission to assign milestone or responsible
            form.add(new Label("responsible").setVisible(false));
            form.add(new Label("milestone").setVisible(false));
        }
        form.add(new Button("update"));
        Button cancel = new Button("cancel") {
            private static final long serialVersionUID = 1L;
            @Override
            public void onSubmit() {
                setResponsePage(TicketsPage.class, getPageParameters());
            }
        };
        cancel.setDefaultFormProcessing(false);
        form.add(cancel);
    }
    @Override
    protected String getPageName() {
        return getString("gb.editTicket");
    }
    @Override
    protected Class<? extends BasePage> getRepoNavPageClass() {
        return TicketsPage.class;
    }
}
src/main/java/com/gitblit/wicket/pages/ExportTicketPage.java
New file
@@ -0,0 +1,82 @@
/*
 * 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.pages;
import org.apache.wicket.IRequestTarget;
import org.apache.wicket.PageParameters;
import org.apache.wicket.RequestCycle;
import org.apache.wicket.protocol.http.WebResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.gitblit.models.RepositoryModel;
import com.gitblit.models.TicketModel;
import com.gitblit.tickets.TicketSerializer;
import com.gitblit.utils.StringUtils;
import com.gitblit.wicket.WicketUtils;
public class ExportTicketPage extends SessionPage {
    private final Logger logger = LoggerFactory.getLogger(getClass().getSimpleName());
    String contentType;
    public ExportTicketPage(final PageParameters params) {
        super(params);
        if (!params.containsKey("r")) {
            error(getString("gb.repositoryNotSpecified"));
            redirectToInterceptPage(new RepositoriesPage());
        }
        getRequestCycle().setRequestTarget(new IRequestTarget() {
            @Override
            public void detach(RequestCycle requestCycle) {
            }
            @Override
            public void respond(RequestCycle requestCycle) {
                WebResponse response = (WebResponse) requestCycle.getResponse();
                final String repositoryName = WicketUtils.getRepositoryName(params);
                RepositoryModel repository = app().repositories().getRepositoryModel(repositoryName);
                String objectId = WicketUtils.getObject(params).toLowerCase();
                if (objectId.endsWith(".json")) {
                    objectId = objectId.substring(0, objectId.length() - ".json".length());
                }
                long id = Long.parseLong(objectId);
                TicketModel ticket = app().tickets().getTicket(repository, id);
                String content = TicketSerializer.serialize(ticket);
                contentType = "application/json; charset=UTF-8";
                response.setContentType(contentType);
                try {
                    response.getOutputStream().write(content.getBytes("UTF-8"));
                } catch (Exception e) {
                    logger.error("Failed to write text response", e);
                }
            }
        });
    }
    @Override
    protected void setHeaders(WebResponse response) {
        super.setHeaders(response);
        if (!StringUtils.isEmpty(contentType)) {
            response.setContentType(contentType);
        }
    }
}
src/main/java/com/gitblit/wicket/pages/NewTicketPage.html
New file
@@ -0,0 +1,66 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:wicket="http://wicket.apache.org/dtds.data/wicket-xhtml1.3-strict.dtd"
      xml:lang="en"
      lang="en">
<wicket:extend>
<body onload="document.getElementById('title').focus();">
<div class="container">
    <!-- page header -->
    <div class="title" style="font-size: 22px; color: rgb(0, 32, 96);padding: 3px 0px 7px;">
        <span class="project"><wicket:message key="gb.newTicket"></wicket:message></span>
    </div>
    <form style="padding-top:5px;" wicket:id="editForm">
    <div class="row">
    <div class="span12">
        <!-- New Ticket Table -->
        <table class="ticket">
            <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>
            <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>
            <tr><th style="vertical-align: top;"><wicket:message key="gb.description"></wicket:message></th><td class="edit">
                <div style="background-color:#fbfbfb;border:1px solid #ccc;">
                <ul class="nav nav-pills" style="margin: 2px 5px !important">
                    <li class="active"><a tabindex="-1" href="#edit" data-toggle="tab"><wicket:message key="gb.edit">[edit]</wicket:message></a></li>
                    <li><a tabindex="-1" 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="edit">
                        <textarea class="input-xxlarge ticket-text-editor" wicket:id="description"></textarea>
                    </div>
                    <div class="tab-pane" id="preview">
                        <div class="preview ticket-text-editor">
                            <div class="markdown input-xxlarge" wicket:id="descriptionPreview"></div>
                        </div>
                    </div>
                </div>
                </div>
            </td></tr>
            <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>
            <tr wicket:id="responsible"></tr>
            <tr wicket:id="milestone"></tr>
        </table>
    </div>
    </div>
    <div class="row">
    <div class="span12">
        <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>
    </div>
    </div>
    </form>
</div>
</body>
<wicket:fragment wicket:id="responsibleFragment">
    <th><wicket:message key="gb.responsible"></wicket:message></th><td class="edit"><select class="input-large" wicket:id="responsible"></select></td>
</wicket:fragment>
<wicket:fragment wicket:id="milestoneFragment">
    <th><wicket:message key="gb.milestone"></wicket:message></th><td class="edit"><select class="input-large" wicket:id="milestone"></select></td>
</wicket:fragment>
</wicket:extend>
</html>
src/main/java/com/gitblit/wicket/pages/NewTicketPage.java
New file
@@ -0,0 +1,202 @@
/*
 * 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.wicket.pages;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.apache.wicket.PageParameters;
import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.form.Button;
import org.apache.wicket.markup.html.form.DropDownChoice;
import org.apache.wicket.markup.html.form.Form;
import org.apache.wicket.markup.html.form.TextField;
import org.apache.wicket.markup.html.panel.Fragment;
import org.apache.wicket.model.IModel;
import org.apache.wicket.model.Model;
import com.gitblit.Constants.AccessPermission;
import com.gitblit.models.RegistrantAccessPermission;
import com.gitblit.models.TicketModel;
import com.gitblit.models.TicketModel.Change;
import com.gitblit.models.TicketModel.Field;
import com.gitblit.models.TicketModel.Status;
import com.gitblit.models.UserModel;
import com.gitblit.tickets.TicketMilestone;
import com.gitblit.tickets.TicketNotifier;
import com.gitblit.tickets.TicketResponsible;
import com.gitblit.utils.StringUtils;
import com.gitblit.wicket.GitBlitWebSession;
import com.gitblit.wicket.WicketUtils;
import com.gitblit.wicket.panels.MarkdownTextArea;
/**
 * Page for creating a new ticket.
 *
 * @author James Moger
 *
 */
public class NewTicketPage extends RepositoryPage {
    private IModel<TicketModel.Type> typeModel;
    private IModel<String> titleModel;
    private MarkdownTextArea descriptionEditor;
    private IModel<String> topicModel;
    private IModel<TicketResponsible> responsibleModel;
    private IModel<TicketMilestone> milestoneModel;
    private Label descriptionPreview;
    public NewTicketPage(PageParameters params) {
        super(params);
        UserModel currentUser = GitBlitWebSession.get().getUser();
        if (currentUser == null) {
            currentUser = UserModel.ANONYMOUS;
        }
        if (!currentUser.isAuthenticated || !app().tickets().isAcceptingNewTickets(getRepositoryModel())) {
            // tickets prohibited
            setResponsePage(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
        }
        typeModel = Model.of(TicketModel.Type.defaultType);
        titleModel = Model.of();
        topicModel = Model.of();
        responsibleModel = Model.of();
        milestoneModel = Model.of();
        setStatelessHint(false);
        setOutputMarkupId(true);
        Form<Void> form = new Form<Void>("editForm") {
            private static final long serialVersionUID = 1L;
            @Override
            protected void onSubmit() {
                String createdBy = GitBlitWebSession.get().getUsername();
                Change change = new Change(createdBy);
                change.setField(Field.title, titleModel.getObject());
                change.setField(Field.body, descriptionEditor.getText());
                String topic = topicModel.getObject();
                if (!StringUtils.isEmpty(topic)) {
                    change.setField(Field.topic, topic);
                }
                // type
                TicketModel.Type type = TicketModel.Type.defaultType;
                if (typeModel.getObject() != null) {
                    type = typeModel.getObject();
                }
                change.setField(Field.type, type);
                // responsible
                TicketResponsible responsible = responsibleModel == null ? null : responsibleModel.getObject();
                if (responsible != null) {
                    change.setField(Field.responsible, responsible.username);
                }
                // milestone
                TicketMilestone milestone = milestoneModel == null ? null : milestoneModel.getObject();
                if (milestone != null) {
                    change.setField(Field.milestone, milestone.name);
                }
                TicketModel ticket = app().tickets().createTicket(getRepositoryModel(), 0L, change);
                if (ticket != null) {
                    TicketNotifier notifier = app().tickets().createNotifier();
                    notifier.sendMailing(ticket);
                    setResponsePage(TicketsPage.class, WicketUtils.newObjectParameter(getRepositoryModel().name, "" + ticket.number));
                } else {
                    // TODO error
                }
            }
        };
        add(form);
        form.add(new DropDownChoice<TicketModel.Type>("type", typeModel, Arrays.asList(TicketModel.Type.choices())));
        form.add(new TextField<String>("title", titleModel));
        form.add(new TextField<String>("topic", topicModel));
        final IModel<String> markdownPreviewModel = new Model<String>();
        descriptionPreview = new Label("descriptionPreview", markdownPreviewModel);
        descriptionPreview.setEscapeModelStrings(false);
        descriptionPreview.setOutputMarkupId(true);
        form.add(descriptionPreview);
        descriptionEditor = new MarkdownTextArea("description", markdownPreviewModel, descriptionPreview);
        descriptionEditor.setRepository(repositoryName);
        form.add(descriptionEditor);
        if (currentUser != null && currentUser.isAuthenticated && currentUser.canPush(getRepositoryModel())) {
            // responsible
            List<TicketResponsible> responsibles = new ArrayList<TicketResponsible>();
            for (RegistrantAccessPermission rp : app().repositories().getUserAccessPermissions(getRepositoryModel())) {
                if (rp.permission.atLeast(AccessPermission.PUSH) && !rp.isTeam()) {
                    UserModel user = app().users().getUserModel(rp.registrant);
                    if (user != null) {
                        responsibles.add(new TicketResponsible(user));
                    }
                }
            }
            Collections.sort(responsibles);
            Fragment responsible = new Fragment("responsible", "responsibleFragment", this);
            responsible.add(new DropDownChoice<TicketResponsible>("responsible", responsibleModel, responsibles));
            form.add(responsible.setVisible(!responsibles.isEmpty()));
            // milestone
            List<TicketMilestone> milestones = app().tickets().getMilestones(getRepositoryModel(), Status.Open);
            Fragment milestone = new Fragment("milestone", "milestoneFragment", this);
            milestone.add(new DropDownChoice<TicketMilestone>("milestone", milestoneModel, milestones));
            form.add(milestone.setVisible(!milestones.isEmpty()));
        } else {
            // user does not have permission to assign milestone or responsible
            form.add(new Label("responsible").setVisible(false));
            form.add(new Label("milestone").setVisible(false));
        }
        form.add(new Button("create"));
        Button cancel = new Button("cancel") {
            private static final long serialVersionUID = 1L;
            @Override
            public void onSubmit() {
                setResponsePage(TicketsPage.class, getPageParameters());
            }
        };
        cancel.setDefaultFormProcessing(false);
        form.add(cancel);
    }
    @Override
    protected String getPageName() {
        return getString("gb.newTicket");
    }
    @Override
    protected Class<? extends BasePage> getRepoNavPageClass() {
        return TicketsPage.class;
    }
}
src/main/java/com/gitblit/wicket/pages/NoTicketsPage.html
New file
@@ -0,0 +1,21 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:wicket="http://wicket.apache.org/dtds.data/wicket-xhtml1.3-strict.dtd"
      xml:lang="en"
      lang="en">
<wicket:extend>
    <!-- No tickets -->
    <div class="featureWelcome">
        <div class="row">
            <div class="icon span2"><i class="fa fa-ticket"></i></div>
            <div class="span9">
                <h1><wicket:message key="gb.tickets"></wicket:message></h1>
                <wicket:message key="gb.ticketsWelcome"></wicket:message>
                <p></p>
                <a wicket:id="newticket" class="btn btn-appmenu"><wicket:message key="gb.createFirstTicket"></wicket:message></a>
            </div>
        </div>
    </div>
</wicket:extend>
</html>
src/main/java/com/gitblit/wicket/pages/NoTicketsPage.java
New file
@@ -0,0 +1,44 @@
/*
 * 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.pages;
import org.apache.wicket.PageParameters;
import org.apache.wicket.markup.html.link.BookmarkablePageLink;
import com.gitblit.models.UserModel;
import com.gitblit.wicket.GitBlitWebSession;
import com.gitblit.wicket.WicketUtils;
public class NoTicketsPage extends RepositoryPage {
    public NoTicketsPage(PageParameters params) {
        super(params);
        UserModel user = GitBlitWebSession.get().getUser();
        boolean isAuthenticated = user != null && user.isAuthenticated;
        add(new BookmarkablePageLink<Void>("newticket", NewTicketPage.class, WicketUtils.newRepositoryParameter(repositoryName)).setVisible(isAuthenticated));
    }
    @Override
    protected String getPageName() {
        return getString("gb.tickets");
    }
    @Override
    protected Class<? extends BasePage> getRepoNavPageClass() {
        return TicketsPage.class;
    }
}
src/main/java/com/gitblit/wicket/pages/RepositoryPage.html
@@ -38,6 +38,7 @@
                    <div>
                        <div class="hidden-phone btn-group pull-right" style="margin-top:5px;">
                            <!-- future spot for other repo buttons -->
                            <a class="btn" wicket:id="newTicketLink"></a>
                            <a class="btn" wicket:id="starLink"></a>
                            <a class="btn" wicket:id="unstarLink"></a>
                            <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>
src/main/java/com/gitblit/wicket/pages/RepositoryPage.java
@@ -56,6 +56,7 @@
import com.gitblit.models.UserRepositoryPreferences;
import com.gitblit.servlet.PagesServlet;
import com.gitblit.servlet.SyndicationServlet;
import com.gitblit.tickets.TicketIndexer.Lucene;
import com.gitblit.utils.ArrayUtils;
import com.gitblit.utils.DeepCopier;
import com.gitblit.utils.JGitUtils;
@@ -95,7 +96,7 @@
    public RepositoryPage(PageParameters params) {
        super(params);
        repositoryName = WicketUtils.getRepositoryName(params);
        String root =StringUtils.getFirstPathElement(repositoryName);
        String root = StringUtils.getFirstPathElement(repositoryName);
        if (StringUtils.isEmpty(root)) {
            projectName = app().settings().getString(Keys.web.repositoryRootGroupName, "main");
        } else {
@@ -200,11 +201,18 @@
        }
        pages.put("commits", new PageRegistration("gb.commits", LogPage.class, params));
        pages.put("tree", new PageRegistration("gb.tree", TreePage.class, params));
        if (app().tickets().isReady() && (app().tickets().isAcceptingNewTickets(getRepositoryModel()) || app().tickets().hasTickets(getRepositoryModel()))) {
            PageParameters tParams = new PageParameters(params);
            for (String state : TicketsPage.openStatii) {
                tParams.add(Lucene.status.name(), state);
            }
            pages.put("tickets", new PageRegistration("gb.tickets", TicketsPage.class, tParams));
        }
        pages.put("docs", new PageRegistration("gb.docs", DocsPage.class, params, true));
        pages.put("compare", new PageRegistration("gb.compare", ComparePage.class, params, true));
        if (app().settings().getBoolean(Keys.web.allowForking, true)) {
            pages.put("forks", new PageRegistration("gb.forks", ForksPage.class, params, true));
        }
        pages.put("compare", new PageRegistration("gb.compare", ComparePage.class, params, true));
        // conditional links
        // per-repository extra page links
@@ -288,6 +296,14 @@
            }
        }
        // new ticket button
        if (user.isAuthenticated && app().tickets().isAcceptingNewTickets(getRepositoryModel())) {
            String newTicketUrl = getRequestCycle().urlFor(NewTicketPage.class, WicketUtils.newRepositoryParameter(repositoryName)).toString();
            addToolbarButton("newTicketLink", "fa fa-ticket", getString("gb.new"), newTicketUrl);
        } else {
            add(new Label("newTicketLink").setVisible(false));
        }
        // (un)star link allows a user to star a repository
        if (user.isAuthenticated) {
            PageParameters starParams = DeepCopier.copy(getPageParameters());
src/main/java/com/gitblit/wicket/pages/TicketBasePage.java
New file
@@ -0,0 +1,124 @@
/*
 * 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.pages;
import org.apache.wicket.PageParameters;
import org.apache.wicket.markup.html.basic.Label;
import com.gitblit.models.TicketModel;
import com.gitblit.models.TicketModel.Status;
import com.gitblit.models.TicketModel.Type;
import com.gitblit.wicket.WicketUtils;
public abstract class TicketBasePage extends RepositoryPage {
    public TicketBasePage(PageParameters params) {
        super(params);
    }
    protected Label getStateIcon(String wicketId, TicketModel ticket) {
        return getStateIcon(wicketId, ticket.type, ticket.status);
    }
    protected Label getStateIcon(String wicketId, Type type, Status state) {
        Label label = new Label(wicketId);
        if (type == null) {
            type = Type.defaultType;
        }
        switch (type) {
        case Proposal:
            WicketUtils.setCssClass(label, "fa fa-code-fork");
            break;
        case Bug:
            WicketUtils.setCssClass(label, "fa fa-bug");
            break;
        case Enhancement:
            WicketUtils.setCssClass(label, "fa fa-magic");
            break;
        case Question:
            WicketUtils.setCssClass(label, "fa fa-question");
            break;
        default:
            // standard ticket
            WicketUtils.setCssClass(label, "fa fa-ticket");
        }
        WicketUtils.setHtmlTooltip(label, getTypeState(type, state));
        return label;
    }
    protected String getTypeState(Type type, Status state) {
        return state.toString() + " " + type.toString();
    }
    protected String getLozengeClass(Status status, boolean subtle) {
        if (status == null) {
            status = Status.New;
        }
        String css = "";
        switch (status) {
        case Declined:
        case Duplicate:
        case Invalid:
        case Wontfix:
            css = "aui-lozenge-error";
            break;
        case Fixed:
        case Merged:
        case Resolved:
            css = "aui-lozenge-success";
            break;
        case New:
            css = "aui-lozenge-complete";
            break;
        case On_Hold:
            css = "aui-lozenge-current";
            break;
        default:
            css = "";
            break;
        }
        return "aui-lozenge" + (subtle ? " aui-lozenge-subtle": "") + (css.isEmpty() ? "" : " ") + css;
    }
    protected String getStatusClass(Status status) {
        String css = "";
        switch (status) {
        case Declined:
        case Duplicate:
        case Invalid:
        case Wontfix:
            css = "resolution-error";
            break;
        case Fixed:
        case Merged:
        case Resolved:
            css = "resolution-success";
            break;
        case New:
            css = "resolution-complete";
            break;
        case On_Hold:
            css = "resolution-current";
            break;
        default:
            css = "";
            break;
        }
        return "resolution" + (css.isEmpty() ? "" : " ") + css;
    }
}
src/main/java/com/gitblit/wicket/pages/TicketPage.html
New file
@@ -0,0 +1,577 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:wicket="http://wicket.apache.org/dtds.data/wicket-xhtml1.3-strict.dtd"
      xml:lang="en"
      lang="en">
<body>
<wicket:extend>
<!-- HEADER -->
<div style="padding: 10px 0px 15px;">
    <div style="display:inline-block;" class="ticket-title"><span wicket:id="ticketTitle">[ticket title]</span></div>
    <a style="padding-left:10px;" class="ticket-number" wicket:id="ticketNumber"></a>
    <div style="display:inline-block;padding: 0px 10px;vertical-align:top;"><span wicket:id="headerStatus"></span></div>
    <div class="hidden-phone hidden-tablet pull-right"><div wicket:id="diffstat"></div></div>
</div>
<!-- TAB NAMES -->
<ul class="nav nav-tabs">
    <li class="active"><a data-toggle="tab" href="#discussion">
        <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>
    </li>
    <li><a data-toggle="tab" href="#commits">
        <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>
    </li>
    <li><a data-toggle="tab" href="#activity">
        <i style="color:#888;"class="fa fa-clock-o"></i> <span class="hidden-phone"><wicket:message key="gb.activity"></wicket:message></span></a>
    </li>
</ul>
<!-- TABS -->
<div class="tab-content">
    <!-- DISCUSSION TAB -->
    <div class="tab-pane active" id="discussion">
        <div class="row">
            <!-- LEFT SIDE -->
            <div class="span8">
                <div class="ticket-meta-middle">
                    <!-- creator -->
                    <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>
                </div>
                <div class="ticket-meta-bottom"">
                    <div class="ticket-text markdown" wicket:id="ticketDescription">[description]</div>
                </div>
                <!-- COMMENTS and STATUS CHANGES (DISCUSSIONS TAB) -->
                <div wicket:id="discussion"></div>
                <!-- ADD COMMENT (DISCUSSIONS TAB) -->
                <div id="addcomment" wicket:id="newComment"></div>
            </div>
            <!-- RIGHT SIDE -->
            <div class="span4 hidden-phone">
                <div class="status-display" style="padding-bottom: 5px;">
                    <div wicket:id="ticketStatus" style="display:block;padding: 5px 10px 10px;">[ticket status]</div>
                </div>
                <div wicket:id="labels" style="border-top: 1px solid #ccc;padding: 5px 0px;">
                    <span class="label ticketLabel" wicket:id="label">[label]</span>
                </div>
                <div wicket:id="controls"></div>
                <div style="border: 1px solid #ccc;padding: 10px;margin: 5px 0px;">
                    <table class="summary" style="width: 100%">
                        <tr><th><wicket:message key="gb.type"></wicket:message></th><td><span wicket:id="ticketType">[type]</span></td></tr>
                        <tr><th><wicket:message key="gb.topic"></wicket:message></th><td><span wicket:id="ticketTopic">[topic]</span></td></tr>
                        <tr><th><wicket:message key="gb.responsible"></wicket:message></th><td><span wicket:id="responsible">[responsible]</span></td></tr>
                        <tr><th><wicket:message key="gb.milestone"></wicket:message></th><td><span wicket:id="milestone">[milestone]</span></td></tr>
                        <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>
                        <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>
                        <tr><th><wicket:message key="gb.export"></wicket:message></th><td><a rel="nofollow" target="_blank" wicket:id="exportJson"></a></td></tr>
                    </table>
                </div>
                <div>
                    <span class="attribution-text" wicket:id="participantsLabel"></span>
                    <span wicket:id="participants"><span style="padding: 0px 2px;" wicket:id="participant"></span></span>
                </div>
            </div>
        </div>
    </div>
    <!-- COMMITS TAB -->
    <div class="tab-pane" id="commits">
        <div wicket:id="patchset"></div>
    </div>
    <!-- ACTIVITY TAB -->
    <div class="tab-pane" id="activity">
        <div wicket:id="activity"></div>
    </div>
</div> <!-- END TABS -->
<!-- BARNUM DOWNLOAD MODAL -->
<div id="ptModal" class="modal hide fade" tabindex="-1" role="dialog" aria-labelledby="ptModalLabel" aria-hidden="true">
  <div class="modal-header">
    <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
    <h3 id="ptModalLabel"><img src="barnum_32x32.png"></img> Barnum <small><wicket:message key="gb.ptDescription"></wicket:message></small></h3>
  </div>
  <div class="modal-body">
    <p><wicket:message key="gb.ptDescription1"></wicket:message></p>
    <h4><wicket:message key="gb.ptSimplifiedCollaboration"></wicket:message></h4>
    <pre class="gitcommand">
pt checkout 123
...
git commit
pt push</pre>
    <h4><wicket:message key="gb.ptSimplifiedMerge"></wicket:message></h4>
    <pre class="gitcommand">pt pull 123</pre>
    <p><wicket:message key="gb.ptDescription2"></wicket:message></p>
  </div>
  <div class="modal-footer">
    <a class="btn btn-appmenu" href="/pt" ><wicket:message key="gb.download"></wicket:message></a>
  </div>
</div>
<!-- MILESTONE PROGRESS FRAGMENT -->
<wicket:fragment wicket:id="milestoneProgressFragment">
    <div style="display:inline-block;padding-right: 10px" wicket:id="link"></div>
    <div style="display:inline-block;margin-bottom: 0px;width: 100px;height:10px;" class="progress progress-success">
        <div class="bar" wicket:id="progress"></div>
    </div>
</wicket:fragment>
<!-- TICKET CONTROLS FRAGMENT -->
<wicket:fragment wicket:id="controlsFragment">
    <div class="hidden-phone hidden-tablet">
        <div class="btn-group" style="display:inline-block;">
            <a class="btn btn-small dropdown-toggle" data-toggle="dropdown" href="#"><wicket:message key="gb.status"></wicket:message> <span class="caret"></span></a>
            <ul class="dropdown-menu">
                <li wicket:id="newStatus"><a wicket:id="link">[status]</a></li>
            </ul>
        </div>
        <div class="btn-group" style="display:inline-block;">
            <a class="btn btn-small dropdown-toggle" data-toggle="dropdown" href="#"><wicket:message key="gb.responsible"></wicket:message> <span class="caret"></span></a>
            <ul class="dropdown-menu">
                <li wicket:id="newResponsible"><a wicket:id="link">[responsible]</a></li>
            </ul>
        </div>
        <div class="btn-group" style="display:inline-block;">
            <a class="btn btn-small dropdown-toggle" data-toggle="dropdown" href="#"><wicket:message key="gb.milestone"></wicket:message> <span class="caret"></span></a>
            <ul class="dropdown-menu">
                <li wicket:id="newMilestone"><a wicket:id="link">[milestone]</a></li>
            </ul>
        </div>
        <div class="btn-group" style="display:inline-block;">
            <a class="btn btn-small" wicket:id="editLink"></a>
        </div>
    </div>
</wicket:fragment>
<!-- STATUS INDICATOR FRAGMENT -->
<wicket:fragment wicket:id="ticketStatusFragment">
    <div style="font-size:2.5em;padding-bottom: 5px;">
        <i wicket:id="ticketIcon">[ticket type]</i>
    </div>
    <div style="font-size:1.5em;" wicket:id="ticketStatus">[ticket status]</div>
</wicket:fragment>
<!-- DISCUSSION FRAGMENT -->
<wicket:fragment wicket:id="discussionFragment">
    <h3 style="padding-top:10px;"><wicket:message key="gb.comments"></wicket:message></h3>
    <div wicket:id="discussion">
        <div style="padding: 10px 0px;" wicket:id="entry"></div>
    </div>
</wicket:fragment>
<!-- NEW COMMENT FRAGMENT -->
<wicket:fragment wicket:id="newCommentFragment">
    <div class="row">
        <div class="span8">
            <hr/>
        </div>
    </div>
    <h3 style="padding:0px 0px 10px;"><wicket:message key="gb.addComment"></wicket:message></h3>
    <div class="row">
        <div class="span1 hidden-phone" style="text-align:right;">
            <span wicket:id="newCommentAvatar">[avatar]</span>
        </div>
        <div class="span7 attribution-border" style="background-color:#fbfbfb;">
            <div class="hidden-phone attribution-triangle"></div>
            <div wicket:id="commentPanel"></div>
        </div>
    </div>
</wicket:fragment>
<!-- COMMENT FRAGMENT -->
<wicket:fragment wicket:id="commentFragment">
<div class="row">
    <div class="span1 hidden-phone" style="text-align:right;">
        <span wicket:id="changeAvatar">[avatar]</span>
    </div>
    <div class="span7 attribution-border">
        <!-- <div class="hidden-phone attribution-triangle"></div> -->
        <div class="attribution-header" style="border-radius:20px;">
             <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>
        </div>
        <div class="markdown attribution-comment">
            <div class="ticket-text" wicket:id="comment">[comment text]</div>
        </div>
    </div>
</div>
</wicket:fragment>
<!-- STATUS CHANGE FRAGMENT -->
<wicket:fragment wicket:id="statusFragment">
<div class="row" style="opacity: 0.5;filter: alpha(opacity=50);">
    <div class="span7 offset1">
        <div style="padding: 8px;border: 1px solid translucent;">
             <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>
        </div>
    </div>
</div>
</wicket:fragment>
<!-- BOUNDARY FRAGMENT -->
<wicket:fragment wicket:id="boundaryFragment">
<div class="row" style="padding: 15px 0px 10px 0px;">
    <div class="span7 offset1" style="border-top: 2px dotted #999;" />
</div>
</wicket:fragment>
<!-- MERGE/CLOSE FRAGMENT -->
<wicket:fragment wicket:id="mergeCloseFragment">
<div wicket:id="merge" style="padding-top: 10px;"></div>
<div wicket:id="close"></div>
<div wicket:id="boundary"></div>
</wicket:fragment>
<!-- MERGE FRAGMENT -->
<wicket:fragment wicket:id="mergeFragment">
<div class="row">
    <div class="span7 offset1">
        <span class="status-change aui-lozenge aui-lozenge-success"><wicket:message key="gb.merged"></wicket:message></span>
        <span class="attribution-emphasize" wicket:id="changeAuthor">[author]</span> <span class="attribution-text"><wicket:message key="gb.mergedPatchset">[merged patchset]</wicket:message></span>
        <span class="attribution-emphasize" wicket:id="commitLink">[commit]</span> <span style="padding-left:2px;" wicket:id="toBranch"></span>
        <p class="attribution-pullright"><span class="attribution-date" wicket:id="changeDate">[change date]</span></p>
    </div>
</div>
</wicket:fragment>
<!-- PROPOSE A PATCHSET FRAGMENT -->
<wicket:fragment wicket:id="proposeFragment">
    <div class="featureWelcome">
        <div class="row">
            <div class="icon span2 hidden-phone"><i class="fa fa-code"></i></div>
            <div class="span9">
                <h1><wicket:message key="gb.proposePatchset"></wicket:message></h1>
                <div class="markdown">
                    <p><wicket:message key="gb.proposePatchsetNote"></wicket:message></p>
                    <p><span wicket:id="proposeInstructions"></span></p>
                    <h4><span wicket:id="gitWorkflow"></span></h4>
                    <div wicket:id="gitWorkflowSteps"></div>
                    <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>
                    <div wicket:id="ptWorkflowSteps"></div>
                </div>
            </div>
        </div>
    </div>
</wicket:fragment>
<!-- PATCHSET FRAGMENT -->
<wicket:fragment wicket:id="patchsetFragment">
    <div class="row" style="padding: 0px 0px 20px;">
         <div class="span12 attribution-border">
            <div wicket:id="panel"></div>
        </div>
    </div>
    <h3><span wicket:id="commitsInPatchset"></span></h3>
    <div class="row">
        <div class="span12">
            <table class="table tickets">
                <thead>
                    <tr>
                        <th class="hidden-phone"><wicket:message key="gb.author"></wicket:message></th>
                        <th ><wicket:message key="gb.commit"></wicket:message></th>
                        <th colspan="2"><wicket:message key="gb.title"></wicket:message></th>
                        <th style="text-align: right;"><wicket:message key="gb.date"></wicket:message></th>
                    </tr>
                </thead>
                <tbody>
                    <tr wicket:id="commit">
                        <td class="hidden-phone"><span wicket:id="authorAvatar">[avatar]</span> <span wicket:id="author">[author]</span></td>
                        <td><span class="shortsha1" wicket:id="commitId">[commit id]</span><span class="hidden-phone" style="padding-left: 20px;" wicket:id="diff">[diff]</span></td>
                        <td><span class="attribution-text" wicket:id="title">[title]</span></td>
                        <td style="padding:8px 0px;text-align:right;"><span style="padding-right:40px;"><span wicket:id="commitDiffStat"></span></span></td>
                        <td style="text-align:right;"><span class="attribution-date" wicket:id="commitDate">[commit date]</span></td>
                    </tr>
                </tbody>
            </table>
        </div>
    </div>
</wicket:fragment>
<!-- COLLAPSIBLE PATCHSET (temp) -->
<wicket:fragment wicket:id="collapsiblePatchsetFragment">
<div wicket:id="mergePanel" style="margin-bottom: 10px;"></div>
<div class="accordion" id="accordionPatchset" style="clear:both;margin: 0px;">
<div class="patch-group">
       <div class="accordion-heading">
        <div class="attribution-patch-pullright">
            <div style="padding-bottom: 2px;">
                <span class="attribution-date" wicket:id="changeDate">[patch date]</span>
            </div>
            <!-- Client commands menu -->
            <div class="btn-group pull-right hidden-phone hidden-tablet">
                <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>
            </div>
            <!-- Compare Patchsets menu -->
            <div class="btn-group pull-right hidden-phone hidden-tablet" style="padding-right: 5px;">
                <a class="btn btn-mini dropdown-toggle" data-toggle="dropdown" href="#">
                    <wicket:message key="gb.compare"></wicket:message> <span class="caret"></span>
                </a>
                <ul class="dropdown-menu">
                    <li><span wicket:id="compareMergeBase"></span></li>
                    <li wicket:id="comparePatch"><span wicket:id="compareLink"></span></li>
                </ul>
            </div>
        </div>
        <div style="padding:8px 10px;">
            <div>
            <span class="attribution-emphasize" wicket:id="changeAuthor">[author]</span> <span class="attribution-text"><span wicket:id="uploadedWhat"></span></span>
            <a wicket:message="title:gb.showHideDetails" data-toggle="collapse" data-parent="#accordionPatchset" href="#bodyPatchset"><i class="fa fa-toggle-down"></i></a>
            </div>
            <div wicket:id="patchsetStat"></div>
        </div>
    </div>
    <div style="padding: 10px;color: #444;background:white;border-top:1px solid #ccc;">
        <div class="pull-right" wicket:id="reviewControls"></div>
        <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>
    </div>
    <div id="bodyPatchset" class="accordion-body collapse" style="clear:both;">
        <div class="accordion-inner">
            <!-- changed paths -->
            <table class="pretty" style="border: 0px;">
                <tr wicket:id="changedPath">
                    <td class="changeType"><span wicket:id="changeType">[change type]</span></td>
                    <td class="path"><span wicket:id="pathName">[commit path]</span></td>
                    <td class="hidden-phone rightAlign">
                        <span class="hidden-tablet" style="padding-right:20px;" wicket:id="diffStat"></span>
                        <span class="link" style="white-space: nowrap;">
                            <a wicket:id="diff"><wicket:message key="gb.diff"></wicket:message></a> | <a wicket:id="view"><wicket:message key="gb.view"></wicket:message></a>
                        </span>
                    </td>
                </tr>
            </table>
        </div>
    </div>
</div>
<div id="bodyCheckout" class="accordion-body collapse" style="background-color:#fbfbfb;clear:both;">
  <div class="alert submit-info" style="padding:4px;">
    <div class="merge-panel" style="border: 1px solid #F1CB82;">
      <div class="ticket-text">
        <h4><wicket:message key="gb.checkoutViaCommandLine"></wicket:message></h4>
        <p><wicket:message key="gb.checkoutViaCommandLineNote"></wicket:message></p>
        <h4>Git</h4>
        <p class="step">
          <b><span wicket:id="gitStep1"></span>:</b> <wicket:message key="gb.checkoutStep1"></wicket:message> <span wicket:id="gitCopyStep1"></span>
        </p>
        <pre wicket:id="gitPreStep1" class="gitcommand"></pre>
        <p class="step">
          <b><span wicket:id="gitStep2"></span>:</b> <wicket:message key="gb.checkoutStep2"></wicket:message> <span wicket:id="gitCopyStep2"></span>
        </p>
        <pre wicket:id="gitPreStep2" class="gitcommand"></pre>
        <hr/>
        <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>
        <p class="step">
          <wicket:message key="gb.ptCheckout"></wicket:message> <span wicket:id="ptCopyStep"></span>
        </p>
        <pre wicket:id="ptPreStep" class="gitcommand"></pre>
     </div>
   </div>
 </div>
</div>
</div>
</wicket:fragment>
<!--ACTIVITY -->
<wicket:fragment wicket:id="activityFragment">
    <table class="table tickets">
        <thead>
            <tr>
                <th><wicket:message key="gb.author"></wicket:message></th>
                <th colspan='3'><wicket:message key="gb.action"></wicket:message></th>
                <th style="text-align: right;"><wicket:message key="gb.date"></wicket:message></th>
            </tr>
        </thead>
        <tbody>
            <tr wicket:id="event">
                <td><span class="hidden-phone" wicket:id="changeAvatar">[avatar]</span> <span class="attribution-emphasize" wicket:id="changeAuthor">[author]</span></td>
                <td>
                    <span class="attribution-txt"><span wicket:id="what">[what happened]</span></span>
                    <div wicket:id="fields"></div>
                </td>
                <td style="text-align:right;">
                    <span wicket:id="patchsetType">[revision type]</span>
                </td>
                <td><span class="hidden-phone hidden-tablet aui-lozenge aui-lozenge-subtle" wicket:id="patchsetRevision">[R1]</span>
                    <span class="hidden-tablet hidden-phone" style="padding-left:15px;"><span wicket:id="patchsetDiffStat"></span></span>
                </td>
                <td style="text-align:right;"><span class="attribution-date" wicket:id="changeDate">[patch date]</span></td>
            </tr>
        </tbody>
    </table>
</wicket:fragment>
<!-- REVIEW CONTROLS -->
<wicket:fragment wicket:id="reviewControlsFragment">
    <div class="btn-group pull-right hidden-phone hidden-tablet">
        <a class="btn btn-mini dropdown-toggle" data-toggle="dropdown" href="#">
            <wicket:message key="gb.review"></wicket:message> <span class="caret"></span>
        </a>
        <ul class="dropdown-menu">
            <li><span><a wicket:id="approveLink">approve</a></span></li>
            <li><span><a wicket:id="looksGoodLink">looks good</a></span></li>
            <li><span><a wicket:id="needsImprovementLink">needs improvement</a></span></li>
            <li><span><a wicket:id="vetoLink">veto</a></span></li>
        </ul>
    </div>
</wicket:fragment>
<!-- MERGEABLE PATCHSET FRAGMENT -->
<wicket:fragment wicket:id="mergeableFragment">
    <div class="alert alert-success submit-info" style="padding:4px;">
        <div class="merge-panel" style="border: 1px solid rgba(70, 136, 70, 0.5);">
            <div class="pull-right" style="padding-top:5px;">
                <a class="btn btn-success" wicket:id="mergeButton"></a>
            </div>
            <h4><i class="fa fa-check-circle"></i> <span wicket:id="mergeTitle"></span></h4>
            <div wicket:id="mergeMore"></div>
        </div>
    </div>
</wicket:fragment>
<!-- COMMAND LINE MERGE INSTRUCTIONS -->
<wicket:fragment wicket:id="commandlineMergeFragment">
    <div class="accordion" id="accordionInstructions" style="margin: 0px;">
        <span wicket:id="instructions"></span>
        <a wicket:message="title:gb.showHideDetails" data-toggle="collapse" data-parent="#accordionInstructions" href="#bodyInstructions"><i class="fa fa-toggle-down"></i></a>
    </div>
    <div id="bodyInstructions" class="ticket-text accordion-body collapse" style="clear:both;">
        <hr/>
        <h4><wicket:message key="gb.mergingViaCommandLine"></wicket:message></h4>
        <p><wicket:message key="gb.mergingViaCommandLineNote"></wicket:message></p>
        <h4>Git</h4>
        <p class="step">
            <b><span wicket:id="mergeStep1"></span>:</b> <wicket:message key="gb.mergeStep1"></wicket:message> <span wicket:id="mergeCopyStep1"></span>
        </p>
        <pre wicket:id="mergePreStep1" class="gitcommand"></pre>
        <p class="step">
            <b><span wicket:id="mergeStep2"></span>:</b> <wicket:message key="gb.mergeStep2"></wicket:message> <span wicket:id="mergeCopyStep2"></span>
        </p>
        <pre wicket:id="mergePreStep2" class="gitcommand"></pre>
        <p class="step">
            <b><span wicket:id="mergeStep3"></span>:</b> <wicket:message key="gb.mergeStep3"></wicket:message> <span wicket:id="mergeCopyStep3"></span>
        </p>
        <pre wicket:id="mergePreStep3" class="gitcommand"></pre>
        <hr/>
        <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>
        <p class="step">
          <wicket:message key="gb.ptMerge"></wicket:message> <span wicket:id="ptMergeCopyStep"></span>
        </p>
        <pre wicket:id="ptMergeStep" class="gitcommand"></pre>
    </div>
</wicket:fragment>
<!-- ALREADY MERGED FRAGMENT -->
<wicket:fragment wicket:id="alreadyMergedFragment">
    <div class="alert alert-success submit-info" style="padding:4px;">
        <div class="merge-panel" style="border: 1px solid rgba(70, 136, 70, 0.5);">
            <h4><i class="fa fa-check-circle"></i> <span wicket:id="mergeTitle"></span></h4>
        </div>
    </div>
</wicket:fragment>
<!-- NOT-MERGEABLE FRAGMENT -->
<wicket:fragment wicket:id="notMergeableFragment">
    <div class="alert alert-error submit-info" style="padding:4px;">
        <div class="merge-panel" style="border: 1px solid rgba(136, 70, 70, 0.5);">
            <h4><i class="fa fa-exclamation-triangle"></i> <span wicket:id="mergeTitle"></span></h4>
            <div wicket:id="mergeMore"></div>
        </div>
    </div>
</wicket:fragment>
<!-- VETOED PATCHSET FRAGMENT -->
<wicket:fragment wicket:id="vetoedFragment">
    <div class="alert alert-error submit-info" style="padding:4px;">
        <div class="merge-panel" style="border: 1px solid rgba(136, 70, 70, 0.5);">
            <h4><i class="fa fa-exclamation-circle"></i> <span wicket:id="mergeTitle"></span></h4>
            <wicket:message key="gb.patchsetVetoedMore"></wicket:message>
        </div>
    </div>
</wicket:fragment>
<!-- NOT APPROVED PATCHSET FRAGMENT -->
<wicket:fragment wicket:id="notApprovedFragment">
    <div class="alert alert-info submit-info" style="padding:4px;">
        <div class="merge-panel" style="border: 1px solid rgba(0, 70, 200, 0.5);">
            <h4><i class="fa fa-minus-circle"></i> <span wicket:id="mergeTitle"></span></h4>
            <div wicket:id="mergeMore"></div>
        </div>
    </div>
</wicket:fragment>
<!-- Plain JavaScript manual copy & paste -->
<wicket:fragment wicket:id="jsPanel">
      <span style="vertical-align:baseline;">
          <img wicket:id="copyIcon" wicket:message="title:gb.copyToClipboard"></img>
      </span>
</wicket:fragment>
<!-- flash-based button-press copy & paste -->
<wicket:fragment wicket:id="clippyPanel">
    <object wicket:message="title:gb.copyToClipboard" style="vertical-align:middle;"
               wicket:id="clippy"
               width="14"
               height="14"
               bgcolor="#ffffff"
               quality="high"
               wmode="transparent"
               scale="noscale"
               allowScriptAccess="always"></object>
</wicket:fragment>
</wicket:extend>
</body>
</html>
src/main/java/com/gitblit/wicket/pages/TicketPage.java
New file
@@ -0,0 +1,1527 @@
/*
 * 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.wicket.pages;
import java.text.DateFormat;
import java.text.MessageFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import java.util.TreeSet;
import javax.servlet.http.HttpServletRequest;
import org.apache.wicket.AttributeModifier;
import org.apache.wicket.Component;
import org.apache.wicket.MarkupContainer;
import org.apache.wicket.PageParameters;
import org.apache.wicket.RestartResponseException;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.behavior.IBehavior;
import org.apache.wicket.behavior.SimpleAttributeModifier;
import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.image.ContextImage;
import org.apache.wicket.markup.html.link.BookmarkablePageLink;
import org.apache.wicket.markup.html.link.ExternalLink;
import org.apache.wicket.markup.html.panel.Fragment;
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.Model;
import org.apache.wicket.protocol.http.WebRequest;
import org.eclipse.jgit.diff.DiffEntry.ChangeType;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.transport.URIish;
import com.gitblit.Constants;
import com.gitblit.Constants.AccessPermission;
import com.gitblit.Keys;
import com.gitblit.git.PatchsetCommand;
import com.gitblit.git.PatchsetReceivePack;
import com.gitblit.models.PathModel.PathChangeModel;
import com.gitblit.models.RegistrantAccessPermission;
import com.gitblit.models.RepositoryModel;
import com.gitblit.models.SubmoduleModel;
import com.gitblit.models.TicketModel;
import com.gitblit.models.TicketModel.Change;
import com.gitblit.models.TicketModel.CommentSource;
import com.gitblit.models.TicketModel.Field;
import com.gitblit.models.TicketModel.Patchset;
import com.gitblit.models.TicketModel.PatchsetType;
import com.gitblit.models.TicketModel.Review;
import com.gitblit.models.TicketModel.Score;
import com.gitblit.models.TicketModel.Status;
import com.gitblit.models.UserModel;
import com.gitblit.tickets.TicketIndexer.Lucene;
import com.gitblit.tickets.TicketLabel;
import com.gitblit.tickets.TicketMilestone;
import com.gitblit.tickets.TicketResponsible;
import com.gitblit.utils.JGitUtils;
import com.gitblit.utils.JGitUtils.MergeStatus;
import com.gitblit.utils.MarkdownUtils;
import com.gitblit.utils.StringUtils;
import com.gitblit.utils.TimeUtils;
import com.gitblit.wicket.GitBlitWebSession;
import com.gitblit.wicket.WicketUtils;
import com.gitblit.wicket.panels.BasePanel.JavascriptTextPrompt;
import com.gitblit.wicket.panels.CommentPanel;
import com.gitblit.wicket.panels.DiffStatPanel;
import com.gitblit.wicket.panels.GravatarImage;
import com.gitblit.wicket.panels.IconAjaxLink;
import com.gitblit.wicket.panels.LinkPanel;
import com.gitblit.wicket.panels.ShockWaveComponent;
import com.gitblit.wicket.panels.SimpleAjaxLink;
/**
 * The ticket page handles viewing and updating a ticket.
 *
 * @author James Moger
 *
 */
public class TicketPage extends TicketBasePage {
    static final String NIL = "<nil>";
    static final String ESC_NIL = StringUtils.escapeForHtml(NIL,  false);
    final int avatarWidth = 40;
    final TicketModel ticket;
    public TicketPage(PageParameters params) {
        super(params);
        final UserModel user = GitBlitWebSession.get().getUser() == null ? UserModel.ANONYMOUS : GitBlitWebSession.get().getUser();
        final boolean isAuthenticated = !UserModel.ANONYMOUS.equals(user) && user.isAuthenticated;
        final RepositoryModel repository = getRepositoryModel();
        final String id = WicketUtils.getObject(params);
        long ticketId = Long.parseLong(id);
        ticket = app().tickets().getTicket(repository, ticketId);
        if (ticket == null) {
            // ticket not found
            throw new RestartResponseException(TicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
        }
        final List<Change> revisions = new ArrayList<Change>();
        List<Change> comments = new ArrayList<Change>();
        List<Change> statusChanges = new ArrayList<Change>();
        List<Change> discussion = new ArrayList<Change>();
        for (Change change : ticket.changes) {
            if (change.hasComment() || (change.isStatusChange() && (change.getStatus() != Status.New))) {
                discussion.add(change);
            }
            if (change.hasComment()) {
                comments.add(change);
            }
            if (change.hasPatchset()) {
                revisions.add(change);
            }
            if (change.isStatusChange() && !change.hasPatchset()) {
                statusChanges.add(change);
            }
        }
        final Change currentRevision = revisions.isEmpty() ? null : revisions.get(revisions.size() - 1);
        final Patchset currentPatchset = ticket.getCurrentPatchset();
        /*
         * TICKET HEADER
         */
        String href = urlFor(TicketsPage.class, params).toString();
        add(new ExternalLink("ticketNumber", href, "#" + ticket.number));
        Label headerStatus = new Label("headerStatus", ticket.status.toString());
        WicketUtils.setCssClass(headerStatus, getLozengeClass(ticket.status, false));
        add(headerStatus);
        add(new Label("ticketTitle", ticket.title));
        if (currentPatchset == null) {
            add(new Label("diffstat").setVisible(false));
        } else {
            // calculate the current diffstat of the patchset
            add(new DiffStatPanel("diffstat", ticket.insertions, ticket.deletions));
        }
        /*
         * TAB TITLES
         */
        add(new Label("commentCount", "" + comments.size()).setVisible(!comments.isEmpty()));
        add(new Label("commitCount", "" + (currentPatchset == null ? 0 : currentPatchset.commits)).setVisible(currentPatchset != null));
        /*
         * TICKET AUTHOR and DATE (DISCUSSION TAB)
         */
        UserModel createdBy = app().users().getUserModel(ticket.createdBy);
        if (createdBy == null) {
            add(new Label("whoCreated", ticket.createdBy));
        } else {
            add(new LinkPanel("whoCreated", null, createdBy.getDisplayName(),
                    UserPage.class, WicketUtils.newUsernameParameter(createdBy.username)));
        }
        if (ticket.isProposal()) {
            // clearly indicate this is a change ticket
            add(new Label("creationMessage", getString("gb.proposedThisChange")));
        } else {
            // standard ticket
            add(new Label("creationMessage", getString("gb.createdThisTicket")));
        }
        String dateFormat = app().settings().getString(Keys.web.datestampLongFormat, "EEEE, MMMM d, yyyy");
        String timestampFormat = app().settings().getString(Keys.web.datetimestampLongFormat, "EEEE, MMMM d, yyyy");
        final TimeZone timezone = getTimeZone();
        final DateFormat df = new SimpleDateFormat(dateFormat);
        df.setTimeZone(timezone);
        final DateFormat tsf = new SimpleDateFormat(timestampFormat);
        tsf.setTimeZone(timezone);
        final Calendar cal = Calendar.getInstance(timezone);
        String fuzzydate;
        TimeUtils tu = getTimeUtils();
        Date createdDate = ticket.created;
        if (TimeUtils.isToday(createdDate, timezone)) {
            fuzzydate = tu.today();
        } else if (TimeUtils.isYesterday(createdDate, timezone)) {
            fuzzydate = tu.yesterday();
        } else {
            // calculate a fuzzy time ago date
            cal.setTime(createdDate);
            cal.set(Calendar.HOUR_OF_DAY, 0);
            cal.set(Calendar.MINUTE, 0);
            cal.set(Calendar.SECOND, 0);
            cal.set(Calendar.MILLISECOND, 0);
            createdDate = cal.getTime();
            fuzzydate = getTimeUtils().timeAgo(createdDate);
        }
        Label when = new Label("whenCreated", fuzzydate + ", " + df.format(createdDate));
        WicketUtils.setHtmlTooltip(when, tsf.format(ticket.created));
        add(when);
        String exportHref = urlFor(ExportTicketPage.class, params).toString();
        add(new ExternalLink("exportJson", exportHref, "json"));
        /*
         * RESPONSIBLE (DISCUSSION TAB)
         */
        if (StringUtils.isEmpty(ticket.responsible)) {
            add(new Label("responsible"));
        } else {
            UserModel responsible = app().users().getUserModel(ticket.responsible);
            if (responsible == null) {
                add(new Label("responsible", ticket.responsible));
            } else {
                add(new LinkPanel("responsible", null, responsible.getDisplayName(),
                        UserPage.class, WicketUtils.newUsernameParameter(responsible.username)));
            }
        }
        /*
         * MILESTONE PROGRESS (DISCUSSION TAB)
         */
        if (StringUtils.isEmpty(ticket.milestone)) {
            add(new Label("milestone"));
        } else {
            // link to milestone query
            TicketMilestone milestone = app().tickets().getMilestone(repository, ticket.milestone);
            PageParameters milestoneParameters = new PageParameters();
            milestoneParameters.put("r", repositoryName);
            milestoneParameters.put(Lucene.milestone.name(), ticket.milestone);
            int progress = 0;
            int open = 0;
            int closed = 0;
            if (milestone != null) {
                progress = milestone.getProgress();
                open = milestone.getOpenTickets();
                closed = milestone.getClosedTickets();
            }
            Fragment milestoneProgress = new Fragment("milestone", "milestoneProgressFragment", this);
            milestoneProgress.add(new LinkPanel("link", null, ticket.milestone, TicketsPage.class, milestoneParameters));
            Label label = new Label("progress");
            WicketUtils.setCssStyle(label, "width:" + progress + "%;");
            milestoneProgress.add(label);
            WicketUtils.setHtmlTooltip(milestoneProgress, MessageFormat.format("{0} open, {1} closed", open, closed));
            add(milestoneProgress);
        }
        /*
         * TICKET DESCRIPTION (DISCUSSION TAB)
         */
        String desc;
        if (StringUtils.isEmpty(ticket.body)) {
            desc = getString("gb.noDescriptionGiven");
        } else {
            desc = MarkdownUtils.transformGFM(app().settings(), ticket.body, ticket.repository);
        }
        add(new Label("ticketDescription", desc).setEscapeModelStrings(false));
        /*
         * PARTICIPANTS (DISCUSSION TAB)
         */
        if (app().settings().getBoolean(Keys.web.allowGravatar, true)) {
            // gravatar allowed
            List<String> participants = ticket.getParticipants();
            add(new Label("participantsLabel", MessageFormat.format(getString(participants.size() > 1 ? "gb.nParticipants" : "gb.oneParticipant"),
                    "<b>" + participants.size() + "</b>")).setEscapeModelStrings(false));
            ListDataProvider<String> participantsDp = new ListDataProvider<String>(participants);
            DataView<String> participantsView = new DataView<String>("participants", participantsDp) {
                private static final long serialVersionUID = 1L;
                @Override
                public void populateItem(final Item<String> item) {
                    String username = item.getModelObject();
                    UserModel user = app().users().getUserModel(username);
                    if (user == null) {
                        user = new UserModel(username);
                    }
                    item.add(new GravatarImage("participant", user.getDisplayName(),
                            user.emailAddress, null, 25, true));
                }
            };
            add(participantsView);
        } else {
            // gravatar prohibited
            add(new Label("participantsLabel").setVisible(false));
            add(new Label("participants").setVisible(false));
        }
        /*
         * LARGE STATUS INDICATOR WITH ICON (DISCUSSION TAB->SIDE BAR)
         */
        Fragment ticketStatus = new Fragment("ticketStatus", "ticketStatusFragment", this);
        Label ticketIcon = getStateIcon("ticketIcon", ticket);
        ticketStatus.add(ticketIcon);
        ticketStatus.add(new Label("ticketStatus", ticket.status.toString()));
        WicketUtils.setCssClass(ticketStatus, getLozengeClass(ticket.status, false));
        add(ticketStatus);
        /*
         * UPDATE FORM (DISCUSSION TAB)
         */
        if (isAuthenticated && app().tickets().isAcceptingTicketUpdates(repository)) {
            Fragment controls = new Fragment("controls", "controlsFragment", this);
            /*
             * STATUS
             */
            List<Status> choices = new ArrayList<Status>();
            if (ticket.isProposal()) {
                choices.addAll(Arrays.asList(TicketModel.Status.proposalWorkflow));
            } else if (ticket.isBug()) {
                choices.addAll(Arrays.asList(TicketModel.Status.bugWorkflow));
            } else {
                choices.addAll(Arrays.asList(TicketModel.Status.requestWorkflow));
            }
            choices.remove(ticket.status);
            ListDataProvider<Status> workflowDp = new ListDataProvider<Status>(choices);
            DataView<Status> statusView = new DataView<Status>("newStatus", workflowDp) {
                private static final long serialVersionUID = 1L;
                @Override
                public void populateItem(final Item<Status> item) {
                    SimpleAjaxLink<Status> link = new SimpleAjaxLink<Status>("link", item.getModel()) {
                        private static final long serialVersionUID = 1L;
                        @Override
                        public void onClick(AjaxRequestTarget target) {
                            Status status = getModel().getObject();
                            Change change = new Change(user.username);
                            change.setField(Field.status, status);
                            if (!ticket.isWatching(user.username)) {
                                change.watch(user.username);
                            }
                            TicketModel update = app().tickets().updateTicket(repository, ticket.number, change);
                            app().tickets().createNotifier().sendMailing(update);
                            setResponsePage(TicketsPage.class, getPageParameters());
                        }
                    };
                    String css = getStatusClass(item.getModel().getObject());
                    WicketUtils.setCssClass(link, css);
                    item.add(link);
                }
            };
            controls.add(statusView);
            /*
             * RESPONSIBLE LIST
             */
            Set<String> userlist = new TreeSet<String>(ticket.getParticipants());
            for (RegistrantAccessPermission rp : app().repositories().getUserAccessPermissions(getRepositoryModel())) {
                if (rp.permission.atLeast(AccessPermission.PUSH) && !rp.isTeam()) {
                    userlist.add(rp.registrant);
                }
            }
            List<TicketResponsible> responsibles = new ArrayList<TicketResponsible>();
            if (!StringUtils.isEmpty(ticket.responsible)) {
                // exclude the current responsible
                userlist.remove(ticket.responsible);
            }
            for (String username : userlist) {
                UserModel u = app().users().getUserModel(username);
                if (u != null) {
                    responsibles.add(new TicketResponsible(u));
                }
            }
            Collections.sort(responsibles);
            responsibles.add(new TicketResponsible(ESC_NIL, "", ""));
            ListDataProvider<TicketResponsible> responsibleDp = new ListDataProvider<TicketResponsible>(responsibles);
            DataView<TicketResponsible> responsibleView = new DataView<TicketResponsible>("newResponsible", responsibleDp) {
                private static final long serialVersionUID = 1L;
                @Override
                public void populateItem(final Item<TicketResponsible> item) {
                    SimpleAjaxLink<TicketResponsible> link = new SimpleAjaxLink<TicketResponsible>("link", item.getModel()) {
                        private static final long serialVersionUID = 1L;
                        @Override
                        public void onClick(AjaxRequestTarget target) {
                            TicketResponsible responsible = getModel().getObject();
                            Change change = new Change(user.username);
                            change.setField(Field.responsible, responsible.username);
                            if (!StringUtils.isEmpty(responsible.username)) {
                                if (!ticket.isWatching(responsible.username)) {
                                    change.watch(responsible.username);
                                }
                            }
                            if (!ticket.isWatching(user.username)) {
                                change.watch(user.username);
                            }
                            TicketModel update = app().tickets().updateTicket(repository, ticket.number, change);
                            app().tickets().createNotifier().sendMailing(update);
                            setResponsePage(TicketsPage.class, getPageParameters());
                        }
                    };
                    item.add(link);
                }
            };
            controls.add(responsibleView);
            /*
             * MILESTONE LIST
             */
            List<TicketMilestone> milestones = app().tickets().getMilestones(repository, Status.Open);
            if (!StringUtils.isEmpty(ticket.milestone)) {
                for (TicketMilestone milestone : milestones) {
                    if (milestone.name.equals(ticket.milestone)) {
                        milestones.remove(milestone);
                        break;
                    }
                }
            }
            milestones.add(new TicketMilestone(ESC_NIL));
            ListDataProvider<TicketMilestone> milestoneDp = new ListDataProvider<TicketMilestone>(milestones);
            DataView<TicketMilestone> milestoneView = new DataView<TicketMilestone>("newMilestone", milestoneDp) {
                private static final long serialVersionUID = 1L;
                @Override
                public void populateItem(final Item<TicketMilestone> item) {
                    SimpleAjaxLink<TicketMilestone> link = new SimpleAjaxLink<TicketMilestone>("link", item.getModel()) {
                        private static final long serialVersionUID = 1L;
                        @Override
                        public void onClick(AjaxRequestTarget target) {
                            TicketMilestone milestone = getModel().getObject();
                            Change change = new Change(user.username);
                            if (NIL.equals(milestone.name)) {
                                change.setField(Field.milestone, "");
                            } else {
                                change.setField(Field.milestone, milestone.name);
                            }
                            if (!ticket.isWatching(user.username)) {
                                change.watch(user.username);
                            }
                            TicketModel update = app().tickets().updateTicket(repository, ticket.number, change);
                            app().tickets().createNotifier().sendMailing(update);
                            setResponsePage(TicketsPage.class, getPageParameters());
                        }
                    };
                    item.add(link);
                }
            };
            controls.add(milestoneView);
            String editHref = urlFor(EditTicketPage.class, params).toString();
            controls.add(new ExternalLink("editLink", editHref, getString("gb.edit")));
            add(controls);
        } else {
            add(new Label("controls").setVisible(false));
        }
        /*
         * TICKET METADATA
         */
        add(new Label("ticketType", ticket.type.toString()));
        if (StringUtils.isEmpty(ticket.topic)) {
            add(new Label("ticketTopic").setVisible(false));
        } else {
            // process the topic using the bugtraq config to link things
            String topic = messageProcessor().processPlainCommitMessage(getRepository(), repositoryName, ticket.topic);
            add(new Label("ticketTopic", topic).setEscapeModelStrings(false));
        }
        /*
         * VOTERS
         */
        List<String> voters = ticket.getVoters();
        Label votersCount = new Label("votes", "" + voters.size());
        if (voters.size() == 0) {
            WicketUtils.setCssClass(votersCount, "badge");
        } else {
            WicketUtils.setCssClass(votersCount, "badge badge-info");
        }
        add(votersCount);
        if (user.isAuthenticated) {
            Model<String> model;
            if (ticket.isVoter(user.username)) {
                model = Model.of(getString("gb.removeVote"));
            } else {
                model = Model.of(MessageFormat.format(getString("gb.vote"), ticket.type.toString()));
            }
            SimpleAjaxLink<String> link = new SimpleAjaxLink<String>("voteLink", model) {
                private static final long serialVersionUID = 1L;
                @Override
                public void onClick(AjaxRequestTarget target) {
                    Change change = new Change(user.username);
                    if (ticket.isVoter(user.username)) {
                        change.unvote(user.username);
                    } else {
                        change.vote(user.username);
                    }
                    app().tickets().updateTicket(repository, ticket.number, change);
                    setResponsePage(TicketsPage.class, getPageParameters());
                }
            };
            add(link);
        } else {
            add(new Label("voteLink").setVisible(false));
        }
        /*
         * WATCHERS
         */
        List<String> watchers = ticket.getWatchers();
        Label watchersCount = new Label("watchers", "" + watchers.size());
        if (watchers.size() == 0) {
            WicketUtils.setCssClass(watchersCount, "badge");
        } else {
            WicketUtils.setCssClass(watchersCount, "badge badge-info");
        }
        add(watchersCount);
        if (user.isAuthenticated) {
            Model<String> model;
            if (ticket.isWatching(user.username)) {
                model = Model.of(getString("gb.stopWatching"));
            } else {
                model = Model.of(MessageFormat.format(getString("gb.watch"), ticket.type.toString()));
            }
            SimpleAjaxLink<String> link = new SimpleAjaxLink<String>("watchLink", model) {
                private static final long serialVersionUID = 1L;
                @Override
                public void onClick(AjaxRequestTarget target) {
                    Change change = new Change(user.username);
                    if (ticket.isWatching(user.username)) {
                        change.unwatch(user.username);
                    } else {
                        change.watch(user.username);
                    }
                    app().tickets().updateTicket(repository, ticket.number, change);
                    setResponsePage(TicketsPage.class, getPageParameters());
                }
            };
            add(link);
        } else {
            add(new Label("watchLink").setVisible(false));
        }
        /*
         * TOPIC & LABELS (DISCUSSION TAB->SIDE BAR)
         */
        ListDataProvider<String> labelsDp = new ListDataProvider<String>(ticket.getLabels());
        DataView<String> labelsView = new DataView<String>("labels", labelsDp) {
            private static final long serialVersionUID = 1L;
            @Override
            public void populateItem(final Item<String> item) {
                final String value = item.getModelObject();
                Label label = new Label("label", value);
                TicketLabel tLabel = app().tickets().getLabel(repository, value);
                String background = MessageFormat.format("background-color:{0};", tLabel.color);
                label.add(new SimpleAttributeModifier("style", background));
                item.add(label);
            }
        };
        add(labelsView);
        /*
         * COMMENTS & STATUS CHANGES (DISCUSSION TAB)
         */
        if (comments.size() == 0) {
            add(new Label("discussion").setVisible(false));
        } else {
            Fragment discussionFragment = new Fragment("discussion", "discussionFragment", this);
            ListDataProvider<Change> discussionDp = new ListDataProvider<Change>(discussion);
            DataView<Change> discussionView = new DataView<Change>("discussion", discussionDp) {
                private static final long serialVersionUID = 1L;
                @Override
                public void populateItem(final Item<Change> item) {
                    final Change entry = item.getModelObject();
                    if (entry.isMerge()) {
                        /*
                         * MERGE
                         */
                        String resolvedBy = entry.getString(Field.mergeSha);
                        // identify the merged patch, it is likely the last
                        Patchset mergedPatch = null;
                        for (Change c : revisions) {
                            if (c.patchset.tip.equals(resolvedBy)) {
                                mergedPatch = c.patchset;
                                break;
                            }
                        }
                        String commitLink;
                        if (mergedPatch == null) {
                            // shouldn't happen, but just-in-case
                            int len = app().settings().getInteger(Keys.web.shortCommitIdLength, 6);
                            commitLink = resolvedBy.substring(0, len);
                        } else {
                            // expected result
                            commitLink = mergedPatch.toString();
                        }
                        Fragment mergeFragment = new Fragment("entry", "mergeFragment", this);
                        mergeFragment.add(new LinkPanel("commitLink", null, commitLink,
                                CommitPage.class, WicketUtils.newObjectParameter(repositoryName, resolvedBy)));
                        mergeFragment.add(new Label("toBranch", MessageFormat.format(getString("gb.toBranch"),
                                "<b>" + ticket.mergeTo + "</b>")).setEscapeModelStrings(false));
                        addUserAttributions(mergeFragment, entry, 0);
                        addDateAttributions(mergeFragment, entry);
                        item.add(mergeFragment);
                    } else if (entry.isStatusChange()) {
                        /*
                         *  STATUS CHANGE
                         */
                        Fragment frag = new Fragment("entry", "statusFragment", this);
                        Label status = new Label("statusChange", entry.getStatus().toString());
                        String css = getLozengeClass(entry.getStatus(), false);
                        WicketUtils.setCssClass(status, css);
                        for (IBehavior b : status.getBehaviors()) {
                            if (b instanceof SimpleAttributeModifier) {
                                SimpleAttributeModifier sam = (SimpleAttributeModifier) b;
                                if ("class".equals(sam.getAttribute())) {
                                    status.add(new SimpleAttributeModifier("class", "status-change " + sam.getValue()));
                                    break;
                                }
                            }
                        }
                        frag.add(status);
                        addUserAttributions(frag, entry, avatarWidth);
                        addDateAttributions(frag, entry);
                        item.add(frag);
                    } else {
                        /*
                         * COMMENT
                         */
                        String comment = MarkdownUtils.transformGFM(app().settings(), entry.comment.text, repositoryName);
                        Fragment frag = new Fragment("entry", "commentFragment", this);
                        Label commentIcon = new Label("commentIcon");
                        if (entry.comment.src == CommentSource.Email) {
                            WicketUtils.setCssClass(commentIcon, "iconic-mail");
                        } else {
                            WicketUtils.setCssClass(commentIcon, "iconic-comment-alt2-stroke");
                        }
                        frag.add(commentIcon);
                        frag.add(new Label("comment", comment).setEscapeModelStrings(false));
                        addUserAttributions(frag, entry, avatarWidth);
                        addDateAttributions(frag, entry);
                        item.add(frag);
                    }
                }
            };
            discussionFragment.add(discussionView);
            add(discussionFragment);
        }
        /*
         * ADD COMMENT PANEL
         */
        if (UserModel.ANONYMOUS.equals(user)
                || !repository.isBare
                || repository.isFrozen
                || repository.isMirror) {
            // prohibit comments for anonymous users, local working copy repos,
            // frozen repos, and mirrors
            add(new Label("newComment").setVisible(false));
        } else {
            // permit user to comment
            Fragment newComment = new Fragment("newComment", "newCommentFragment", this);
            GravatarImage img = new GravatarImage("newCommentAvatar", user.username, user.emailAddress,
                    "gravatar-round", avatarWidth, true);
            newComment.add(img);
            CommentPanel commentPanel = new CommentPanel("commentPanel", user, ticket, null, TicketsPage.class);
            commentPanel.setRepository(repositoryName);
            newComment.add(commentPanel);
            add(newComment);
        }
        /*
         *  PATCHSET TAB
         */
        if (currentPatchset == null) {
            // no patchset yet, show propose fragment
            String repoUrl = getRepositoryUrl(user, repository);
            Fragment changeIdFrag = new Fragment("patchset", "proposeFragment", this);
            changeIdFrag.add(new Label("proposeInstructions", MarkdownUtils.transformMarkdown(getString("gb.proposeInstructions"))).setEscapeModelStrings(false));
            changeIdFrag.add(new Label("ptWorkflow", MessageFormat.format(getString("gb.proposeWith"), "Barnum")));
            changeIdFrag.add(new Label("ptWorkflowSteps", getProposeWorkflow("propose_pt.md", repoUrl, ticket.number)).setEscapeModelStrings(false));
            changeIdFrag.add(new Label("gitWorkflow", MessageFormat.format(getString("gb.proposeWith"), "Git")));
            changeIdFrag.add(new Label("gitWorkflowSteps", getProposeWorkflow("propose_git.md", repoUrl, ticket.number)).setEscapeModelStrings(false));
            add(changeIdFrag);
        } else {
            // show current patchset
            Fragment patchsetFrag = new Fragment("patchset", "patchsetFragment", this);
            patchsetFrag.add(new Label("commitsInPatchset", MessageFormat.format(getString("gb.commitsInPatchsetN"), currentPatchset.number)));
            // current revision
            MarkupContainer panel = createPatchsetPanel("panel", repository, user);
            patchsetFrag.add(panel);
            addUserAttributions(patchsetFrag, currentRevision, avatarWidth);
            addUserAttributions(panel, currentRevision, 0);
            addDateAttributions(panel, currentRevision);
            // commits
            List<RevCommit> commits = JGitUtils.getRevLog(getRepository(), currentPatchset.base, currentPatchset.tip);
            ListDataProvider<RevCommit> commitsDp = new ListDataProvider<RevCommit>(commits);
            DataView<RevCommit> commitsView = new DataView<RevCommit>("commit", commitsDp) {
                private static final long serialVersionUID = 1L;
                @Override
                public void populateItem(final Item<RevCommit> item) {
                    RevCommit commit = item.getModelObject();
                    PersonIdent author = commit.getAuthorIdent();
                    item.add(new GravatarImage("authorAvatar", author.getName(), author.getEmailAddress(), null, 16, false));
                    item.add(new Label("author", commit.getAuthorIdent().getName()));
                    item.add(new LinkPanel("commitId", null, getShortObjectId(commit.getName()),
                            CommitPage.class, WicketUtils.newObjectParameter(repositoryName, commit.getName()), true));
                    item.add(new LinkPanel("diff", "link", getString("gb.diff"), CommitDiffPage.class,
                            WicketUtils.newObjectParameter(repositoryName, commit.getName()), true));
                    item.add(new Label("title", StringUtils.trimString(commit.getShortMessage(), Constants.LEN_SHORTLOG_REFS)));
                    item.add(WicketUtils.createDateLabel("commitDate", JGitUtils.getCommitDate(commit), GitBlitWebSession
                            .get().getTimezone(), getTimeUtils(), false));
                    item.add(new DiffStatPanel("commitDiffStat", 0, 0, true));
                }
            };
            patchsetFrag.add(commitsView);
            add(patchsetFrag);
        }
        /*
         * ACTIVITY TAB
         */
        Fragment revisionHistory = new Fragment("activity", "activityFragment", this);
        List<Change> events = new ArrayList<Change>(ticket.changes);
        Collections.sort(events);
        Collections.reverse(events);
        ListDataProvider<Change> eventsDp = new ListDataProvider<Change>(events);
        DataView<Change> eventsView = new DataView<Change>("event", eventsDp) {
            private static final long serialVersionUID = 1L;
            @Override
            public void populateItem(final Item<Change> item) {
                Change event = item.getModelObject();
                addUserAttributions(item, event, 16);
                if (event.hasPatchset()) {
                    // patchset
                    Patchset patchset = event.patchset;
                    String what;
                    if (event.isStatusChange() && (Status.New == event.getStatus())) {
                        what = getString("gb.proposedThisChange");
                    } else if (patchset.rev == 1) {
                        what = MessageFormat.format(getString("gb.uploadedPatchsetN"), patchset.number);
                    } else {
                        if (patchset.added == 1) {
                            what = getString("gb.addedOneCommit");
                        } else {
                            what = MessageFormat.format(getString("gb.addedNCommits"), patchset.added);
                        }
                    }
                    item.add(new Label("what", what));
                    LinkPanel psr = new LinkPanel("patchsetRevision", null, patchset.number + "-" + patchset.rev,
                            ComparePage.class, WicketUtils.newRangeParameter(repositoryName, patchset.parent == null ? patchset.base : patchset.parent, patchset.tip), true);
                    WicketUtils.setHtmlTooltip(psr, patchset.toString());
                    item.add(psr);
                    String typeCss = getPatchsetTypeCss(patchset.type);
                    Label typeLabel = new Label("patchsetType", patchset.type.toString());
                    if (typeCss == null) {
                        typeLabel.setVisible(false);
                    } else {
                        WicketUtils.setCssClass(typeLabel, typeCss);
                    }
                    item.add(typeLabel);
                    // show commit diffstat
                    item.add(new DiffStatPanel("patchsetDiffStat", patchset.insertions, patchset.deletions, patchset.rev > 1));
                } else if (event.hasComment()) {
                    // comment
                    item.add(new Label("what", getString("gb.commented")));
                    item.add(new Label("patchsetRevision").setVisible(false));
                    item.add(new Label("patchsetType").setVisible(false));
                    item.add(new Label("patchsetDiffStat").setVisible(false));
                } else if (event.hasReview()) {
                    // review
                    String score;
                    switch (event.review.score) {
                    case approved:
                        score = "<span style='color:darkGreen'>" + getScoreDescription(event.review.score) + "</span>";
                        break;
                    case vetoed:
                        score = "<span style='color:darkRed'>" + getScoreDescription(event.review.score) + "</span>";
                        break;
                    default:
                        score = getScoreDescription(event.review.score);
                    }
                    item.add(new Label("what", MessageFormat.format(getString("gb.reviewedPatchsetRev"),
                            event.review.patchset, event.review.rev, score))
                            .setEscapeModelStrings(false));
                    item.add(new Label("patchsetRevision").setVisible(false));
                    item.add(new Label("patchsetType").setVisible(false));
                    item.add(new Label("patchsetDiffStat").setVisible(false));
                } else {
                    // field change
                    item.add(new Label("patchsetRevision").setVisible(false));
                    item.add(new Label("patchsetType").setVisible(false));
                    item.add(new Label("patchsetDiffStat").setVisible(false));
                    String what = "";
                    if (event.isStatusChange()) {
                    switch (event.getStatus()) {
                    case New:
                        if (ticket.isProposal()) {
                            what = getString("gb.proposedThisChange");
                        } else {
                            what = getString("gb.createdThisTicket");
                        }
                        break;
                    default:
                        break;
                    }
                    }
                    item.add(new Label("what", what).setVisible(what.length() > 0));
                }
                addDateAttributions(item, event);
                if (event.hasFieldChanges()) {
                    StringBuilder sb = new StringBuilder();
                    sb.append("<table class=\"summary\"><tbody>");
                    for (Map.Entry<Field, String> entry : event.fields.entrySet()) {
                        String value;
                        switch (entry.getKey()) {
                            case body:
                                String body = entry.getValue();
                                if (event.isStatusChange() && Status.New == event.getStatus() && StringUtils.isEmpty(body)) {
                                    // ignore initial empty description
                                    continue;
                                }
                                // trim body changes
                                if (StringUtils.isEmpty(body)) {
                                    value = "<i>" + ESC_NIL + "</i>";
                                } else {
                                    value = StringUtils.trimString(body, Constants.LEN_SHORTLOG_REFS);
                                }
                                break;
                            case status:
                                // special handling for status
                                Status status = event.getStatus();
                                String css = getLozengeClass(status, true);
                                value = String.format("<span class=\"%1$s\">%2$s</span>", css, status.toString());
                                break;
                            default:
                                value = StringUtils.isEmpty(entry.getValue()) ? ("<i>" + ESC_NIL + "</i>") : StringUtils.escapeForHtml(entry.getValue(), false);
                                break;
                        }
                        sb.append("<tr><th style=\"width:70px;\">");
                        sb.append(entry.getKey().name());
                        sb.append("</th><td>");
                        sb.append(value);
                        sb.append("</td></tr>");
                    }
                    sb.append("</tbody></table>");
                    item.add(new Label("fields", sb.toString()).setEscapeModelStrings(false));
                } else {
                    item.add(new Label("fields").setVisible(false));
                }
            }
        };
        revisionHistory.add(eventsView);
        add(revisionHistory);
    }
    protected void addUserAttributions(MarkupContainer container, Change entry, int avatarSize) {
        UserModel commenter = app().users().getUserModel(entry.author);
        if (commenter == null) {
            // unknown user
            container.add(new GravatarImage("changeAvatar", entry.author,
                    entry.author, null, avatarSize, false).setVisible(avatarSize > 0));
            container.add(new Label("changeAuthor", entry.author.toLowerCase()));
        } else {
            // known user
            container.add(new GravatarImage("changeAvatar", commenter.getDisplayName(),
                    commenter.emailAddress, avatarSize > 24 ? "gravatar-round" : null,
                            avatarSize, true).setVisible(avatarSize > 0));
            container.add(new LinkPanel("changeAuthor", null, commenter.getDisplayName(),
                    UserPage.class, WicketUtils.newUsernameParameter(commenter.username)));
        }
    }
    protected void addDateAttributions(MarkupContainer container, Change entry) {
        container.add(WicketUtils.createDateLabel("changeDate", entry.date, GitBlitWebSession
                .get().getTimezone(), getTimeUtils(), false));
        // set the id attribute
        if (entry.hasComment()) {
            container.setOutputMarkupId(true);
            container.add(new AttributeModifier("id", Model.of(entry.getId())));
            ExternalLink link = new ExternalLink("changeLink", "#" + entry.getId());
            container.add(link);
        } else {
            container.add(new Label("changeLink").setVisible(false));
        }
    }
    protected String getProposeWorkflow(String resource, String url, long ticketId) {
        String md = readResource(resource);
        md = md.replace("${url}", url);
        md = md.replace("${repo}", StringUtils.getLastPathElement(StringUtils.stripDotGit(repositoryName)));
        md = md.replace("${ticketId}", "" + ticketId);
        md = md.replace("${patchset}", "" + 1);
        md = md.replace("${reviewBranch}", Repository.shortenRefName(PatchsetCommand.getTicketBranch(ticketId)));
        md = md.replace("${integrationBranch}", Repository.shortenRefName(getRepositoryModel().HEAD));
        return MarkdownUtils.transformMarkdown(md);
    }
    protected Fragment createPatchsetPanel(String wicketId, RepositoryModel repository, UserModel user) {
        final Patchset currentPatchset = ticket.getCurrentPatchset();
        List<Patchset> patchsets = new ArrayList<Patchset>(ticket.getPatchsetRevisions(currentPatchset.number));
        patchsets.remove(currentPatchset);
        Collections.reverse(patchsets);
        Fragment panel = new Fragment(wicketId, "collapsiblePatchsetFragment", this);
        // patchset header
        String ps = "<b>" + currentPatchset.number + "</b>";
        if (currentPatchset.rev == 1) {
            panel.add(new Label("uploadedWhat", MessageFormat.format(getString("gb.uploadedPatchsetN"), ps)).setEscapeModelStrings(false));
        } else {
            String rev = "<b>" + currentPatchset.rev + "</b>";
            panel.add(new Label("uploadedWhat", MessageFormat.format(getString("gb.uploadedPatchsetNRevisionN"), ps, rev)).setEscapeModelStrings(false));
        }
        panel.add(new LinkPanel("patchId", null, "rev " + currentPatchset.rev,
                CommitPage.class, WicketUtils.newObjectParameter(repositoryName, currentPatchset.tip), true));
        // compare menu
        panel.add(new LinkPanel("compareMergeBase", null, getString("gb.compareToMergeBase"),
                ComparePage.class, WicketUtils.newRangeParameter(repositoryName, currentPatchset.base, currentPatchset.tip), true));
        ListDataProvider<Patchset> compareMenuDp = new ListDataProvider<Patchset>(patchsets);
        DataView<Patchset> compareMenu = new DataView<Patchset>("comparePatch", compareMenuDp) {
            private static final long serialVersionUID = 1L;
            @Override
            public void populateItem(final Item<Patchset> item) {
                Patchset patchset = item.getModelObject();
                LinkPanel link = new LinkPanel("compareLink", null,
                        MessageFormat.format(getString("gb.compareToN"), patchset.number + "-" + patchset.rev),
                        ComparePage.class, WicketUtils.newRangeParameter(getRepositoryModel().name,
                                patchset.tip, currentPatchset.tip), true);
                item.add(link);
            }
        };
        panel.add(compareMenu);
        // reviews
        List<Change> reviews = ticket.getReviews(currentPatchset);
        ListDataProvider<Change> reviewsDp = new ListDataProvider<Change>(reviews);
        DataView<Change> reviewsView = new DataView<Change>("reviews", reviewsDp) {
            private static final long serialVersionUID = 1L;
            @Override
            public void populateItem(final Item<Change> item) {
                Change change = item.getModelObject();
                final String username = change.author;
                UserModel user = app().users().getUserModel(username);
                if (user == null) {
                    item.add(new Label("reviewer", username));
                } else {
                    item.add(new LinkPanel("reviewer", null, user.getDisplayName(),
                            UserPage.class, WicketUtils.newUsernameParameter(username)));
                }
                // indicate review score
                Review review = change.review;
                Label scoreLabel = new Label("score");
                String scoreClass = getScoreClass(review.score);
                String tooltip = getScoreDescription(review.score);
                WicketUtils.setCssClass(scoreLabel, scoreClass);
                if (!StringUtils.isEmpty(tooltip)) {
                    WicketUtils.setHtmlTooltip(scoreLabel, tooltip);
                }
                item.add(scoreLabel);
            }
        };
        panel.add(reviewsView);
        if (ticket.isOpen() && user.canReviewPatchset(repository)) {
            // can only review open tickets
            Review myReview = null;
            for (Change change : ticket.getReviews(currentPatchset)) {
                if (change.author.equals(user.username)) {
                    myReview = change.review;
                }
            }
            // user can review, add review controls
            Fragment reviewControls = new Fragment("reviewControls", "reviewControlsFragment", this);
            // show "approve" button if no review OR not current score
            if (user.canApprovePatchset(repository) && (myReview == null || Score.approved != myReview.score)) {
                reviewControls.add(createReviewLink("approveLink", Score.approved));
            } else {
                reviewControls.add(new Label("approveLink").setVisible(false));
            }
            // show "looks good" button if no review OR not current score
            if (myReview == null || Score.looks_good != myReview.score) {
                reviewControls.add(createReviewLink("looksGoodLink", Score.looks_good));
            } else {
                reviewControls.add(new Label("looksGoodLink").setVisible(false));
            }
            // show "needs improvement" button if no review OR not current score
            if (myReview == null || Score.needs_improvement != myReview.score) {
                reviewControls.add(createReviewLink("needsImprovementLink", Score.needs_improvement));
            } else {
                reviewControls.add(new Label("needsImprovementLink").setVisible(false));
            }
            // show "veto" button if no review OR not current score
            if (user.canVetoPatchset(repository) && (myReview == null || Score.vetoed != myReview.score)) {
                reviewControls.add(createReviewLink("vetoLink", Score.vetoed));
            } else {
                reviewControls.add(new Label("vetoLink").setVisible(false));
            }
            panel.add(reviewControls);
        } else {
            // user can not review
            panel.add(new Label("reviewControls").setVisible(false));
        }
        String insertions = MessageFormat.format("<span style=\"color:darkGreen;font-weight:bold;\">+{0}</span>", ticket.insertions);
        String deletions = MessageFormat.format("<span style=\"color:darkRed;font-weight:bold;\">-{0}</span>", ticket.deletions);
        panel.add(new Label("patchsetStat", MessageFormat.format(StringUtils.escapeForHtml(getString("gb.diffStat"), false),
                insertions, deletions)).setEscapeModelStrings(false));
        // changed paths list
        List<PathChangeModel> paths = JGitUtils.getFilesInRange(getRepository(), currentPatchset.base, currentPatchset.tip);
        ListDataProvider<PathChangeModel> pathsDp = new ListDataProvider<PathChangeModel>(paths);
        DataView<PathChangeModel> pathsView = new DataView<PathChangeModel>("changedPath", pathsDp) {
            private static final long serialVersionUID = 1L;
            int counter;
            @Override
            public void populateItem(final Item<PathChangeModel> item) {
                final PathChangeModel entry = item.getModelObject();
                Label changeType = new Label("changeType", "");
                WicketUtils.setChangeTypeCssClass(changeType, entry.changeType);
                setChangeTypeTooltip(changeType, entry.changeType);
                item.add(changeType);
                item.add(new DiffStatPanel("diffStat", entry.insertions, entry.deletions, true));
                boolean hasSubmodule = false;
                String submodulePath = null;
                if (entry.isTree()) {
                    // tree
                    item.add(new LinkPanel("pathName", null, entry.path, TreePage.class,
                            WicketUtils
                                    .newPathParameter(repositoryName, currentPatchset.tip, entry.path), true));
                    item.add(new Label("diffStat").setVisible(false));
                } else if (entry.isSubmodule()) {
                    // submodule
                    String submoduleId = entry.objectId;
                    SubmoduleModel submodule = getSubmodule(entry.path);
                    submodulePath = submodule.gitblitPath;
                    hasSubmodule = submodule.hasSubmodule;
                    item.add(new LinkPanel("pathName", "list", entry.path + " @ " +
                            getShortObjectId(submoduleId), TreePage.class,
                            WicketUtils.newPathParameter(submodulePath, submoduleId, ""), true).setEnabled(hasSubmodule));
                    item.add(new Label("diffStat").setVisible(false));
                } else {
                    // blob
                    String displayPath = entry.path;
                    String path = entry.path;
                    if (entry.isSymlink()) {
                        RevCommit commit = JGitUtils.getCommit(getRepository(), Constants.R_TICKETS_PATCHSETS + ticket.number);
                        path = JGitUtils.getStringContent(getRepository(), commit.getTree(), path);
                        displayPath = entry.path + " -> " + path;
                    }
                    if (entry.changeType.equals(ChangeType.ADD)) {
                        // add show view
                        item.add(new LinkPanel("pathName", "list", displayPath, BlobPage.class,
                                WicketUtils.newPathParameter(repositoryName, currentPatchset.tip, path), true));
                    } else if (entry.changeType.equals(ChangeType.DELETE)) {
                        // delete, show label
                        item.add(new Label("pathName", displayPath));
                    } else {
                        // mod, show diff
                        item.add(new LinkPanel("pathName", "list", displayPath, BlobDiffPage.class,
                                WicketUtils.newPathParameter(repositoryName, currentPatchset.tip, path), true));
                    }
                }
                // quick links
                if (entry.isSubmodule()) {
                    // submodule
                    item.add(setNewTarget(new BookmarkablePageLink<Void>("diff", BlobDiffPage.class, WicketUtils
                            .newPathParameter(repositoryName, entry.commitId, entry.path)))
                            .setEnabled(!entry.changeType.equals(ChangeType.ADD)));
                    item.add(new BookmarkablePageLink<Void>("view", CommitPage.class, WicketUtils
                            .newObjectParameter(submodulePath, entry.objectId)).setEnabled(hasSubmodule));
                } else {
                    // tree or blob
                    item.add(setNewTarget(new BookmarkablePageLink<Void>("diff", BlobDiffPage.class, WicketUtils
                            .newBlobDiffParameter(repositoryName, currentPatchset.base, currentPatchset.tip, entry.path)))
                            .setEnabled(!entry.changeType.equals(ChangeType.ADD)
                                    && !entry.changeType.equals(ChangeType.DELETE)));
                    item.add(setNewTarget(new BookmarkablePageLink<Void>("view", BlobPage.class, WicketUtils
                            .newPathParameter(repositoryName, currentPatchset.tip, entry.path)))
                            .setEnabled(!entry.changeType.equals(ChangeType.DELETE)));
                }
                WicketUtils.setAlternatingBackground(item, counter);
                counter++;
            }
        };
        panel.add(pathsView);
        addPtReviewInstructions(user, repository, panel);
        addGitReviewInstructions(user, repository, panel);
        panel.add(createMergePanel(user, repository));
        return panel;
    }
    protected IconAjaxLink<String> createReviewLink(String wicketId, final Score score) {
        return new IconAjaxLink<String>(wicketId, getScoreClass(score), Model.of(getScoreDescription(score))) {
            private static final long serialVersionUID = 1L;
            @Override
            public void onClick(AjaxRequestTarget target) {
                review(score);
            }
        };
    }
    protected String getScoreClass(Score score) {
        switch (score) {
        case vetoed:
            return "fa fa-exclamation-circle";
        case needs_improvement:
            return "fa fa-thumbs-o-down";
        case looks_good:
            return "fa fa-thumbs-o-up";
        case approved:
            return "fa fa-check-circle";
        case not_reviewed:
        default:
            return "fa fa-minus-circle";
        }
    }
    protected String getScoreDescription(Score score) {
        String description;
        switch (score) {
        case vetoed:
            description = getString("gb.veto");
            break;
        case needs_improvement:
            description = getString("gb.needsImprovement");
            break;
        case looks_good:
            description = getString("gb.looksGood");
            break;
        case approved:
            description = getString("gb.approve");
            break;
        case not_reviewed:
        default:
            description = getString("gb.hasNotReviewed");
        }
        return String.format("%1$s (%2$+d)", description, score.getValue());
    }
    protected void review(Score score) {
        UserModel user = GitBlitWebSession.get().getUser();
        Patchset ps = ticket.getCurrentPatchset();
        Change change = new Change(user.username);
        change.review(ps, score, !ticket.isReviewer(user.username));
        if (!ticket.isWatching(user.username)) {
            change.watch(user.username);
        }
        TicketModel updatedTicket = app().tickets().updateTicket(getRepositoryModel(), ticket.number, change);
        app().tickets().createNotifier().sendMailing(updatedTicket);
        setResponsePage(TicketsPage.class, getPageParameters());
    }
    protected <X extends MarkupContainer> X setNewTarget(X x) {
        x.add(new SimpleAttributeModifier("target", "_blank"));
        return x;
    }
    protected void addGitReviewInstructions(UserModel user, RepositoryModel repository, MarkupContainer panel) {
        String repoUrl = getRepositoryUrl(user, repository);
        panel.add(new Label("gitStep1", MessageFormat.format(getString("gb.stepN"), 1)));
        panel.add(new Label("gitStep2", MessageFormat.format(getString("gb.stepN"), 2)));
        String ticketBranch  = Repository.shortenRefName(PatchsetCommand.getTicketBranch(ticket.number));
        String reviewBranch = PatchsetCommand.getReviewBranch(ticket.number);
        String step1 = MessageFormat.format("git fetch {0} {1}", repoUrl, ticketBranch);
        String step2 = MessageFormat.format("git checkout -B {0} FETCH_HEAD", reviewBranch);
        panel.add(new Label("gitPreStep1", step1));
        panel.add(new Label("gitPreStep2", step2));
        panel.add(createCopyFragment("gitCopyStep1", step1.replace("\n", " && ")));
        panel.add(createCopyFragment("gitCopyStep2", step2.replace("\n", " && ")));
    }
    protected void addPtReviewInstructions(UserModel user, RepositoryModel repository, MarkupContainer panel) {
        String step1 = MessageFormat.format("pt checkout {0,number,0}", ticket.number);
        panel.add(new Label("ptPreStep", step1));
        panel.add(createCopyFragment("ptCopyStep", step1));
    }
    /**
     * Adds a merge panel for the patchset to the markup container.  The panel
     * may just a message if the patchset can not be merged.
     *
     * @param c
     * @param user
     * @param repository
     */
    protected Component createMergePanel(UserModel user, RepositoryModel repository) {
        Patchset patchset = ticket.getCurrentPatchset();
        if (patchset == null) {
            // no patchset to merge
            return new Label("mergePanel");
        }
        boolean allowMerge;
        if (repository.requireApproval) {
            // rpeository requires approval
            allowMerge = ticket.isOpen() && ticket.isApproved(patchset);
        } else {
            // vetos are binding
            allowMerge = ticket.isOpen() && !ticket.isVetoed(patchset);
        }
        MergeStatus mergeStatus = JGitUtils.canMerge(getRepository(), patchset.tip, ticket.mergeTo);
        if (allowMerge) {
            if (MergeStatus.MERGEABLE == mergeStatus) {
                // patchset can be cleanly merged to integration branch OR has already been merged
                Fragment mergePanel = new Fragment("mergePanel", "mergeableFragment", this);
                mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetMergeable"), ticket.mergeTo)));
                if (user.canPush(repository)) {
                    // user can merge locally
                    SimpleAjaxLink<String> mergeButton = new SimpleAjaxLink<String>("mergeButton", Model.of(getString("gb.merge"))) {
                        private static final long serialVersionUID = 1L;
                        @Override
                        public void onClick(AjaxRequestTarget target) {
                            // ensure the patchset is still current AND not vetoed
                            Patchset patchset = ticket.getCurrentPatchset();
                            final TicketModel refreshedTicket = app().tickets().getTicket(getRepositoryModel(), ticket.number);
                            if (patchset.equals(refreshedTicket.getCurrentPatchset())) {
                                // patchset is current, check for recent veto
                                if (!refreshedTicket.isVetoed(patchset)) {
                                    // patchset is not vetoed
                                    // execute the merge using the ticket service
                                    app().tickets().exec(new Runnable() {
                                        @Override
                                        public void run() {
                                            PatchsetReceivePack rp = new PatchsetReceivePack(
                                                    app().gitblit(),
                                                    getRepository(),
                                                    getRepositoryModel(),
                                                    GitBlitWebSession.get().getUser());
                                            MergeStatus result = rp.merge(refreshedTicket);
                                            if (MergeStatus.MERGED == result) {
                                                // notify participants and watchers
                                                rp.sendAll();
                                            } else {
                                                // merge failure
                                                String msg = MessageFormat.format("Failed to merge ticket {0,number,0}: {1}", ticket.number, result.name());
                                                logger.error(msg);
                                                GitBlitWebSession.get().cacheErrorMessage(msg);
                                            }
                                        }
                                    });
                                } else {
                                    // vetoed patchset
                                    String msg = MessageFormat.format("Can not merge ticket {0,number,0}, patchset {1,number,0} has been vetoed!",
                                            ticket.number, patchset.number);
                                    GitBlitWebSession.get().cacheErrorMessage(msg);
                                    logger.error(msg);
                                }
                            } else {
                                // not current patchset
                                String msg = MessageFormat.format("Can not merge ticket {0,number,0}, the patchset has been updated!", ticket.number);
                                GitBlitWebSession.get().cacheErrorMessage(msg);
                                logger.error(msg);
                            }
                            setResponsePage(TicketsPage.class, getPageParameters());
                        }
                    };
                    mergePanel.add(mergeButton);
                    Component instructions = getMergeInstructions(user, repository, "mergeMore", "gb.patchsetMergeableMore");
                    mergePanel.add(instructions);
                } else {
                    mergePanel.add(new Label("mergeButton").setVisible(false));
                    mergePanel.add(new Label("mergeMore").setVisible(false));
                }
                return mergePanel;
            } else if (MergeStatus.ALREADY_MERGED == mergeStatus) {
                // patchset already merged
                Fragment mergePanel = new Fragment("mergePanel", "alreadyMergedFragment", this);
                mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetAlreadyMerged"), ticket.mergeTo)));
                return mergePanel;
            } else {
                // patchset can not be cleanly merged
                Fragment mergePanel = new Fragment("mergePanel", "notMergeableFragment", this);
                mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetNotMergeable"), ticket.mergeTo)));
                if (user.canPush(repository)) {
                    // user can merge locally
                    Component instructions = getMergeInstructions(user, repository, "mergeMore", "gb.patchsetNotMergeableMore");
                    mergePanel.add(instructions);
                } else {
                    mergePanel.add(new Label("mergeMore").setVisible(false));
                }
                return mergePanel;
            }
        } else {
            // merge not allowed
            if (MergeStatus.ALREADY_MERGED == mergeStatus) {
                // patchset already merged
                Fragment mergePanel = new Fragment("mergePanel", "alreadyMergedFragment", this);
                mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetAlreadyMerged"), ticket.mergeTo)));
                return mergePanel;
            } else if (ticket.isVetoed(patchset)) {
                // patchset has been vetoed
                Fragment mergePanel =  new Fragment("mergePanel", "vetoedFragment", this);
                mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetNotMergeable"), ticket.mergeTo)));
                return mergePanel;
            } else if (repository.requireApproval) {
                // patchset has been not been approved for merge
                Fragment mergePanel = new Fragment("mergePanel", "notApprovedFragment", this);
                mergePanel.add(new Label("mergeTitle", MessageFormat.format(getString("gb.patchsetNotApproved"), ticket.mergeTo)));
                mergePanel.add(new Label("mergeMore", MessageFormat.format(getString("gb.patchsetNotApprovedMore"), ticket.mergeTo)));
                return mergePanel;
            } else {
                // other case
                return new Label("mergePanel");
            }
        }
    }
    protected Component getMergeInstructions(UserModel user, RepositoryModel repository, String markupId, String infoKey) {
        Fragment cmd = new Fragment(markupId, "commandlineMergeFragment", this);
        cmd.add(new Label("instructions", MessageFormat.format(getString(infoKey), ticket.mergeTo)));
        String repoUrl = getRepositoryUrl(user, repository);
        // git instructions
        cmd.add(new Label("mergeStep1", MessageFormat.format(getString("gb.stepN"), 1)));
        cmd.add(new Label("mergeStep2", MessageFormat.format(getString("gb.stepN"), 2)));
        cmd.add(new Label("mergeStep3", MessageFormat.format(getString("gb.stepN"), 3)));
        String ticketBranch = Repository.shortenRefName(PatchsetCommand.getTicketBranch(ticket.number));
        String reviewBranch = PatchsetCommand.getReviewBranch(ticket.number);
        String step1 = MessageFormat.format("git checkout -B {0} {1}", reviewBranch, ticket.mergeTo);
        String step2 = MessageFormat.format("git pull {0} {1}", repoUrl, ticketBranch);
        String step3 = MessageFormat.format("git checkout {0}\ngit merge {1}\ngit push origin {0}", ticket.mergeTo, reviewBranch);
        cmd.add(new Label("mergePreStep1", step1));
        cmd.add(new Label("mergePreStep2", step2));
        cmd.add(new Label("mergePreStep3", step3));
        cmd.add(createCopyFragment("mergeCopyStep1", step1.replace("\n", " && ")));
        cmd.add(createCopyFragment("mergeCopyStep2", step2.replace("\n", " && ")));
        cmd.add(createCopyFragment("mergeCopyStep3", step3.replace("\n", " && ")));
        // pt instructions
        String ptStep = MessageFormat.format("pt pull {0,number,0}", ticket.number);
        cmd.add(new Label("ptMergeStep", ptStep));
        cmd.add(createCopyFragment("ptMergeCopyStep", step1.replace("\n", " && ")));
        return cmd;
    }
    /**
     * Returns the primary repository url
     *
     * @param user
     * @param repository
     * @return the primary repository url
     */
    protected String getRepositoryUrl(UserModel user, RepositoryModel repository) {
        HttpServletRequest req = ((WebRequest) getRequest()).getHttpServletRequest();
        String primaryurl = app().gitblit().getRepositoryUrls(req, user, repository).get(0).url;
        String url = primaryurl;
        try {
            url = new URIish(primaryurl).setUser(null).toString();
        } catch (Exception e) {
        }
        return url;
    }
    /**
     * Returns the ticket (if any) that this commit references.
     *
     * @param commit
     * @return null or a ticket
     */
    protected TicketModel getTicket(RevCommit commit) {
        try {
            Map<String, Ref> refs = getRepository().getRefDatabase().getRefs(Constants.R_TICKETS_PATCHSETS);
            for (Map.Entry<String, Ref> entry : refs.entrySet()) {
                if (entry.getValue().getObjectId().equals(commit.getId())) {
                    long id = PatchsetCommand.getTicketNumber(entry.getKey());
                    TicketModel ticket = app().tickets().getTicket(getRepositoryModel(), id);
                    return ticket;
                }
            }
        } catch (Exception e) {
            logger().error("failed to determine ticket from ref", e);
        }
        return null;
    }
    protected String getPatchsetTypeCss(PatchsetType type) {
        String typeCss;
        switch (type) {
            case Rebase:
            case Rebase_Squash:
                typeCss = getLozengeClass(Status.Declined, false);
                break;
            case Squash:
            case Amend:
                typeCss = getLozengeClass(Status.On_Hold, false);
                break;
            case Proposal:
                typeCss = getLozengeClass(Status.New, false);
                break;
            case FastForward:
            default:
                typeCss = null;
            break;
        }
        return typeCss;
    }
    @Override
    protected String getPageName() {
        return getString("gb.ticket");
    }
    @Override
    protected Class<? extends BasePage> getRepoNavPageClass() {
        return TicketsPage.class;
    }
    @Override
    protected String getPageTitle(String repositoryName) {
        return "#" + ticket.number + " - " + ticket.title;
    }
    protected Fragment createCopyFragment(String wicketId, String text) {
        if (app().settings().getBoolean(Keys.web.allowFlashCopyToClipboard, true)) {
            // clippy: flash-based copy & paste
            Fragment copyFragment = new Fragment(wicketId, "clippyPanel", this);
            String baseUrl = WicketUtils.getGitblitURL(getRequest());
            ShockWaveComponent clippy = new ShockWaveComponent("clippy", baseUrl + "/clippy.swf");
            clippy.setValue("flashVars", "text=" + StringUtils.encodeURL(text));
            copyFragment.add(clippy);
            return copyFragment;
        } else {
            // javascript: manual copy & paste with modal browser prompt dialog
            Fragment copyFragment = new Fragment(wicketId, "jsPanel", this);
            ContextImage img = WicketUtils.newImage("copyIcon", "clippy.png");
            img.add(new JavascriptTextPrompt("onclick", "Copy to Clipboard (Ctrl+C, Enter)", text));
            copyFragment.add(img);
            return copyFragment;
        }
    }
}
src/main/java/com/gitblit/wicket/pages/TicketsPage.html
New file
@@ -0,0 +1,215 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:wicket="http://wicket.apache.org/dtds.data/wicket-xhtml1.3-strict.dtd"
      xml:lang="en"
      lang="en">
<body>
<wicket:extend>
    <!-- search tickets form -->
    <div class="hidden-phone pull-right">
        <form class="form-search" style="margin: 0px;" wicket:id="ticketSearchForm">
            <div class="input-append">
                <input type="text" class="search-query" style="width: 170px;border-radius: 14px 0 0 14px; padding-left: 14px;" id="ticketSearchBox" wicket:id="ticketSearchBox" value=""/>
                <button class="btn" style="border-radius: 0 14px 14px 0px;margin-left:-5px;" type="submit"><i class="icon-search"></i></button>
            </div>
        </form>
    </div>
    <ul class="nav nav-tabs">
        <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>
        <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>
    </ul>
    <div class="tab-content">
    <div class="tab-pane active" id="tickets">
    <div class="row" style="min-height:400px;" >
        <!-- query controls -->
        <div class="span3">
            <div wicket:id="milestonePanel"></div>
            <div class="hidden-phone">
                <ul class="nav nav-list">
                      <li class="nav-header"><wicket:message key="gb.queries"></wicket:message></li>
                      <li><a wicket:id="changesQuery"><i class="fa fa-code-fork"></i> <wicket:message key="gb.proposalTickets"></wicket:message></a></li>
                    <li><a wicket:id="bugsQuery"><i class="fa fa-bug"></i> <wicket:message key="gb.bugTickets"></wicket:message></a></li>
                    <li><a wicket:id="enhancementsQuery"><i class="fa fa-magic"></i> <wicket:message key="gb.enhancementTickets"></wicket:message></a></li>
                    <li><a wicket:id="tasksQuery"><i class="fa fa-ticket"></i> <wicket:message key="gb.taskTickets"></wicket:message></a></li>
                    <li><a wicket:id="questionsQuery"><i class="fa fa-question"></i> <wicket:message key="gb.questionTickets"></wicket:message></a></li>
                      <li wicket:id="userDivider" class="divider"></li>
                      <li><a wicket:id="createdQuery"><i class="fa fa-user"></i> <wicket:message key="gb.yourCreatedTickets"></wicket:message></a></li>
                      <li><a wicket:id="watchedQuery"><i class="fa fa-eye"></i> <wicket:message key="gb.yourWatchedTickets"></wicket:message></a></li>
                      <li><a wicket:id="mentionsQuery"><i class="fa fa-comment"></i> <wicket:message key="gb.mentionsMeTickets"></wicket:message></a></li>
                      <li class="divider"></li>
                      <li><a wicket:id="resetQuery"><i class="fa fa-bolt"></i> <wicket:message key="gb.reset"></wicket:message></a></li>
                </ul>
            </div>
            <div wicket:id="dynamicQueries" class="hidden-phone"></div>
        </div>
        <!-- tickets -->
        <div class="span9">
            <div class="btn-toolbar" style="margin-top: 0px;">
                <div class="btn-group">
                      <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>
                      <ul class="dropdown-menu">
                        <li><a wicket:id="openTickets">open</a></li>
                        <li><a wicket:id="closedTickets">closed</a></li>
                        <li><a wicket:id="allTickets">all</a></li>
                        <li class="divider"></li>
                        <li wicket:id="statii"><span wicket:id="statusLink"></span></li>
                      </ul>
                </div>
                <div class="btn-group hidden-phone">
                      <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>
                      <ul class="dropdown-menu">
                        <li wicket:id="responsible"><span wicket:id="responsibleLink"></span></li>
                        <li class="divider"></li>
                        <li><a wicket:id="resetResponsible"><i class="fa fa-bolt"></i> <wicket:message key="gb.reset"></wicket:message></a></li>
                      </ul>
                </div>
                <div class="btn-group">
                      <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>
                      <ul class="dropdown-menu">
                        <li wicket:id="sort"><span wicket:id="sortLink"></span></li>
                      </ul>
                </div>
                <div class="btn-group pull-right">
                    <div class="pagination pagination-right pagination-small">
                          <ul>
                              <li><a wicket:id="prevLink"><i class="fa fa-angle-double-left"></i></a></li>
                            <li wicket:id="pageLink"><span wicket:id="page"></span></li>
                            <li><a wicket:id="nextLink"><i class="fa fa-angle-double-right"></i></a></li>
                          </ul>
                    </div>
                </div>
            </div>
            <table class="table tickets">
            <tbody>
               <tr wicket:id="ticket">
                   <td class="ticket-list-icon">
                       <i wicket:id="state"></i>
                   </td>
                <td>
                    <span wicket:id="title">[title]</span> <span wicket:id="labels" style="font-weight: normal;color:white;"><span class="label" wicket:id="label"></span></span>
                    <div class="ticket-list-details">
                        <span style="padding-right: 10px;" class="hidden-phone">
                            <wicket:message key="gb.createdBy"></wicket:message>
                            <span style="padding: 0px 2px" wicket:id="createdBy">[createdBy]</span> <span class="date" wicket:id="createDate">[create date]</span>
                        </span>
                        <span wicket:id="indicators" style="white-space:nowrap;"><i wicket:id="icon"></i> <span style="padding-right:10px;" wicket:id="count"></span></span>
                    </div>
                    <div class="hidden-phone" wicket:id="updated"></div>
                </td>
                <td class="ticket-list-state">
                       <span class="badge badge-info" wicket:id="votes"></span>
                </td>
                <td class="hidden-phone ticket-list-state">
                       <i wicket:message="title:gb.watching" style="color:#888;" class="fa fa-eye" wicket:id="watching"></i>
                </td>
                <td class="ticket-list-state">
                       <div wicket:id="status"></div>
                </td>
                <td class="indicators">
                    <div>
                         <b>#<span wicket:id="id">[id]</span></b>
                     </div>
                    <div wicket:id="responsible"></div>
                </td>
               </tr>
            </tbody>
            </table>
            <div class="btn-group pull-right">
                    <div class="pagination pagination-right pagination-small">
                          <ul>
                              <li><a wicket:id="prevLink"><i class="fa fa-angle-double-left"></i></a></li>
                            <li wicket:id="pageLink"><span wicket:id="page"></span></li>
                            <li><a wicket:id="nextLink"><i class="fa fa-angle-double-right"></i></a></li>
                          </ul>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <div class="tab-pane" id="milestones">
        <div class="row">
            <div class="span9" wicket:id="milestoneList">
                <h3><span wicket:id="milestoneName"></span> <small><span wicket:id="milestoneState"></span></small></h3>
                <span wicket:id="milestoneDue"></span>
            </div>
        </div>
    </div>
</div>
<wicket:fragment wicket:id="noMilestoneFragment">
<table style="width: 100%;padding-bottom: 5px;">
<tbody>
<tr>
    <td style="color:#888;"><wicket:message key="gb.noMilestoneSelected"></wicket:message></td>
    <td><div wicket:id="milestoneDropdown"></div></td>
</tr>
</tbody>
</table>
</wicket:fragment>
<wicket:fragment wicket:id="milestoneProgressFragment">
<table style="width: 100%;padding-bottom: 5px;">
<tbody>
<tr>
    <td style="color:#888;">
        <div><i style="color:#888;"class="fa fa-bullseye"></i> <span style="font-weight:bold;" wicket:id="currentMilestone"></span></div>
        <div><i style="color:#888;"class="fa fa-calendar"></i> <span style="font-weight:bold;" wicket:id="currentDueDate"></span></div>
    </td>
    <td>
        <div wicket:id="milestoneDropdown"></div>
    </td>
</tr>
</tbody>
</table>
<div style="clear:both;padding-bottom: 10px;">
    <div style="margin-bottom: 5px;" class="progress progress-success">
        <div class="bar" wicket:id="progress"></div>
    </div>
    <div class="milestoneOverview">
        <span wicket:id="openTickets" />,
        <span wicket:id="closedTickets" />,
        <span wicket:id="totalTickets" />
    </div>
</div>
</wicket:fragment>
<wicket:fragment wicket:id="milestoneDropdownFragment">
<div class="btn-group pull-right">
    <a class="btn dropdown-toggle" data-toggle="dropdown" href="#"><i class="fa fa-gear"></i> <span class="caret"></span></a>
    <ul class="dropdown-menu">
        <li wicket:id="milestone"><span wicket:id="milestoneLink">[milestone]</span></li>
        <li class="divider"></li>
        <li><a wicket:id="resetMilestone"><i class="fa fa-bolt"></i> <wicket:message key="gb.reset"></wicket:message></a></li>
    </ul>
</div>
</wicket:fragment>
<wicket:fragment wicket:id="dynamicQueriesFragment">
    <hr/>
    <ul class="nav nav-list">
        <li class="nav-header"><wicket:message key="gb.topicsAndLabels"></wicket:message></li>
        <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>
    </ul>
</wicket:fragment>
<wicket:fragment wicket:id="updatedFragment">
    <div class="ticket-list-details">
        <wicket:message key="gb.updatedBy"></wicket:message>
        <span style="padding: 0px 2px" wicket:id="updatedBy">[updatedBy]</span> <span class="date" wicket:id="updateDate">[update date]</span>
    </div>
</wicket:fragment>
</wicket:extend>
</body>
</html>
src/main/java/com/gitblit/wicket/pages/TicketsPage.java
New file
@@ -0,0 +1,878 @@
/*
 * 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.pages;
import java.io.Serializable;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import org.apache.wicket.Component;
import org.apache.wicket.PageParameters;
import org.apache.wicket.behavior.SimpleAttributeModifier;
import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.form.TextField;
import org.apache.wicket.markup.html.link.BookmarkablePageLink;
import org.apache.wicket.markup.html.panel.Fragment;
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.IModel;
import org.apache.wicket.model.Model;
import org.apache.wicket.request.target.basic.RedirectRequestTarget;
import com.gitblit.Constants;
import com.gitblit.Constants.AccessPermission;
import com.gitblit.Keys;
import com.gitblit.models.RegistrantAccessPermission;
import com.gitblit.models.TicketModel;
import com.gitblit.models.TicketModel.Status;
import com.gitblit.models.UserModel;
import com.gitblit.tickets.QueryBuilder;
import com.gitblit.tickets.QueryResult;
import com.gitblit.tickets.TicketIndexer.Lucene;
import com.gitblit.tickets.TicketLabel;
import com.gitblit.tickets.TicketMilestone;
import com.gitblit.tickets.TicketResponsible;
import com.gitblit.utils.ArrayUtils;
import com.gitblit.utils.StringUtils;
import com.gitblit.wicket.GitBlitWebSession;
import com.gitblit.wicket.SessionlessForm;
import com.gitblit.wicket.WicketUtils;
import com.gitblit.wicket.panels.GravatarImage;
import com.gitblit.wicket.panels.LinkPanel;
public class TicketsPage extends TicketBasePage {
    final TicketResponsible any;
    public static final String [] openStatii = new String [] { Status.New.name().toLowerCase(), Status.Open.name().toLowerCase() };
    public static final String [] closedStatii = new String [] { "!" + Status.New.name().toLowerCase(), "!" + Status.Open.name().toLowerCase() };
    public TicketsPage(PageParameters params) {
        super(params);
        if (!app().tickets().isReady()) {
            // tickets prohibited
            setResponsePage(SummaryPage.class, WicketUtils.newRepositoryParameter(repositoryName));
        } else if (!app().tickets().hasTickets(getRepositoryModel())) {
            // no tickets for this repository
            setResponsePage(NoTicketsPage.class, WicketUtils.newRepositoryParameter(repositoryName));
        } else {
            String id = WicketUtils.getObject(params);
            if (id != null) {
                // view the ticket with the TicketPage
                setResponsePage(TicketPage.class, params);
            }
        }
        // set stateless page preference
        setStatelessHint(true);
        any = new TicketResponsible("any", "[* TO *]", null);
        UserModel user = GitBlitWebSession.get().getUser();
        boolean isAuthenticated = user != null && user.isAuthenticated;
        final String [] statiiParam = params.getStringArray(Lucene.status.name());
        final String assignedToParam = params.getString(Lucene.responsible.name(), null);
        final String milestoneParam = params.getString(Lucene.milestone.name(), null);
        final String queryParam = params.getString("q", null);
        final String searchParam = params.getString("s", null);
        final String sortBy = Lucene.fromString(params.getString("sort", Lucene.created.name())).name();
        final boolean desc = !"asc".equals(params.getString("direction", "desc"));
        // add search form
        TicketSearchForm searchForm = new TicketSearchForm("ticketSearchForm", repositoryName, searchParam);
        add(searchForm);
        searchForm.setTranslatedAttributes();
        final String activeQuery;
        if (!StringUtils.isEmpty(searchParam)) {
            activeQuery = searchParam;
        } else if (StringUtils.isEmpty(queryParam)) {
            activeQuery = "";
        } else {
            activeQuery = queryParam;
        }
        // build Lucene query from defaults and request parameters
        QueryBuilder qb = new QueryBuilder(queryParam);
        if (!qb.containsField(Lucene.rid.name())) {
            // specify the repository
            qb.and(Lucene.rid.matches(getRepositoryModel().getRID()));
        }
        if (!qb.containsField(Lucene.responsible.name())) {
            // specify the responsible
            qb.and(Lucene.responsible.matches(assignedToParam));
        }
        if (!qb.containsField(Lucene.milestone.name())) {
            // specify the milestone
            qb.and(Lucene.milestone.matches(milestoneParam));
        }
        if (!qb.containsField(Lucene.status.name()) && !ArrayUtils.isEmpty(statiiParam)) {
            // specify the states
            boolean not = false;
            QueryBuilder q = new QueryBuilder();
            for (String state : statiiParam) {
                if (state.charAt(0) == '!') {
                    not = true;
                    q.and(Lucene.status.doesNotMatch(state.substring(1)));
                } else {
                    q.or(Lucene.status.matches(state));
                }
            }
            if (not) {
                qb.and(q.toString());
            } else {
                qb.and(q.toSubquery().toString());
            }
        }
        final String luceneQuery = qb.build();
        // open milestones
        List<TicketMilestone> milestones = app().tickets().getMilestones(getRepositoryModel(), Status.Open);
        TicketMilestone currentMilestone = null;
        if (!StringUtils.isEmpty(milestoneParam)) {
            for (TicketMilestone tm : milestones) {
                if (tm.name.equals(milestoneParam)) {
                    // get the milestone (queries the index)
                    currentMilestone = app().tickets().getMilestone(getRepositoryModel(), milestoneParam);
                    break;
                }
            }
            if (currentMilestone == null) {
                // milestone not found, create a temporary one
                currentMilestone = new TicketMilestone(milestoneParam);
            }
        }
        Fragment milestonePanel;
        if (currentMilestone == null) {
            milestonePanel = new Fragment("milestonePanel", "noMilestoneFragment", this);
            add(milestonePanel);
        } else {
            milestonePanel = new Fragment("milestonePanel", "milestoneProgressFragment", this);
            milestonePanel.add(new Label("currentMilestone", currentMilestone.name));
            if (currentMilestone.due == null) {
                milestonePanel.add(new Label("currentDueDate", getString("gb.notSpecified")));
            } else {
                milestonePanel.add(WicketUtils.createDateLabel("currentDueDate", currentMilestone.due, GitBlitWebSession
                        .get().getTimezone(), getTimeUtils(), false));
            }
            Label label = new Label("progress");
            WicketUtils.setCssStyle(label, "width:" + currentMilestone.getProgress() + "%;");
            milestonePanel.add(label);
            milestonePanel.add(new LinkPanel("openTickets", null,
                    currentMilestone.getOpenTickets() + " open",
                    TicketsPage.class,
                    queryParameters(null, currentMilestone.name, openStatii, null, sortBy, desc, 1)));
            milestonePanel.add(new LinkPanel("closedTickets", null,
                    currentMilestone.getClosedTickets() + " closed",
                    TicketsPage.class,
                    queryParameters(null, currentMilestone.name, closedStatii, null, sortBy, desc, 1)));
            milestonePanel.add(new Label("totalTickets", currentMilestone.getTotalTickets() + " total"));
            add(milestonePanel);
        }
        Fragment milestoneDropdown = new Fragment("milestoneDropdown", "milestoneDropdownFragment", this);
        PageParameters resetMilestone = queryParameters(queryParam, null, statiiParam, assignedToParam, sortBy, desc, 1);
        milestoneDropdown.add(new BookmarkablePageLink<Void>("resetMilestone", TicketsPage.class, resetMilestone));
        ListDataProvider<TicketMilestone> milestonesDp = new ListDataProvider<TicketMilestone>(milestones);
        DataView<TicketMilestone> milestonesMenu = new DataView<TicketMilestone>("milestone", milestonesDp) {
            private static final long serialVersionUID = 1L;
            @Override
            public void populateItem(final Item<TicketMilestone> item) {
                final TicketMilestone tm = item.getModelObject();
                PageParameters params = queryParameters(queryParam, tm.name, statiiParam, assignedToParam, sortBy, desc, 1);
                item.add(new LinkPanel("milestoneLink", null, tm.name, TicketsPage.class, params).setRenderBodyOnly(true));
            }
        };
        milestoneDropdown.add(milestonesMenu);
        milestonePanel.add(milestoneDropdown);
        // search or query tickets
        int page = Math.max(1,  WicketUtils.getPage(params));
        int pageSize = app().settings().getInteger(Keys.tickets.perPage, 25);
        List<QueryResult> results;
        if (StringUtils.isEmpty(searchParam)) {
            results = app().tickets().queryFor(luceneQuery, page, pageSize, sortBy, desc);
        } else {
            results = app().tickets().searchFor(getRepositoryModel(), searchParam, page, pageSize);
        }
        int totalResults = results.size() == 0 ? 0 : results.get(0).totalResults;
        // standard queries
        add(new BookmarkablePageLink<Void>("changesQuery", TicketsPage.class,
                queryParameters(
                        Lucene.type.matches(TicketModel.Type.Proposal.name()),
                        milestoneParam,
                        statiiParam,
                        assignedToParam,
                        sortBy,
                        desc,
                        1)));
        add(new BookmarkablePageLink<Void>("bugsQuery", TicketsPage.class,
                queryParameters(
                        Lucene.type.matches(TicketModel.Type.Bug.name()),
                        milestoneParam,
                        statiiParam,
                        assignedToParam,
                        sortBy,
                        desc,
                        1)));
        add(new BookmarkablePageLink<Void>("enhancementsQuery", TicketsPage.class,
                queryParameters(
                        Lucene.type.matches(TicketModel.Type.Enhancement.name()),
                        milestoneParam,
                        statiiParam,
                        assignedToParam,
                        sortBy,
                        desc,
                        1)));
        add(new BookmarkablePageLink<Void>("tasksQuery", TicketsPage.class,
                queryParameters(
                        Lucene.type.matches(TicketModel.Type.Task.name()),
                        milestoneParam,
                        statiiParam,
                        assignedToParam,
                        sortBy,
                        desc,
                        1)));
        add(new BookmarkablePageLink<Void>("questionsQuery", TicketsPage.class,
                queryParameters(
                        Lucene.type.matches(TicketModel.Type.Question.name()),
                        milestoneParam,
                        statiiParam,
                        assignedToParam,
                        sortBy,
                        desc,
                        1)));
        add(new BookmarkablePageLink<Void>("resetQuery", TicketsPage.class,
                queryParameters(
                        null,
                        milestoneParam,
                        openStatii,
                        null,
                        null,
                        true,
                        1)));
        if (isAuthenticated) {
            add(new Label("userDivider"));
            add(new BookmarkablePageLink<Void>("createdQuery", TicketsPage.class,
                    queryParameters(
                            Lucene.createdby.matches(user.username),
                            milestoneParam,
                            statiiParam,
                            assignedToParam,
                            sortBy,
                            desc,
                            1)));
            add(new BookmarkablePageLink<Void>("watchedQuery", TicketsPage.class,
                    queryParameters(
                            Lucene.watchedby.matches(user.username),
                            milestoneParam,
                            statiiParam,
                            assignedToParam,
                            sortBy,
                            desc,
                            1)));
            add(new BookmarkablePageLink<Void>("mentionsQuery", TicketsPage.class,
                    queryParameters(
                            Lucene.mentions.matches(user.username),
                            milestoneParam,
                            statiiParam,
                            assignedToParam,
                            sortBy,
                            desc,
                            1)));
        } else {
            add(new Label("userDivider").setVisible(false));
            add(new Label("createdQuery").setVisible(false));
            add(new Label("watchedQuery").setVisible(false));
            add(new Label("mentionsQuery").setVisible(false));
        }
        Set<TicketQuery> dynamicQueries = new TreeSet<TicketQuery>();
        for (TicketLabel label : app().tickets().getLabels(getRepositoryModel())) {
            String q = QueryBuilder.q(Lucene.labels.matches(label.name)).build();
            dynamicQueries.add(new TicketQuery(label.name, q).color(label.color));
        }
        for (QueryResult ticket : results) {
            if (!StringUtils.isEmpty(ticket.topic)) {
                String q = QueryBuilder.q(Lucene.topic.matches(ticket.topic)).build();
                dynamicQueries.add(new TicketQuery(ticket.topic, q));
            }
            if (!ArrayUtils.isEmpty(ticket.labels)) {
                for (String label : ticket.labels) {
                    String q = QueryBuilder.q(Lucene.labels.matches(label)).build();
                    dynamicQueries.add(new TicketQuery(label, q));
                }
            }
        }
        if (dynamicQueries.size() == 0) {
            add(new Label("dynamicQueries").setVisible(false));
        } else {
            Fragment fragment = new Fragment("dynamicQueries", "dynamicQueriesFragment", this);
            ListDataProvider<TicketQuery> dynamicQueriesDp = new ListDataProvider<TicketQuery>(new ArrayList<TicketQuery>(dynamicQueries));
            DataView<TicketQuery> dynamicQueriesList = new DataView<TicketQuery>("dynamicQuery", dynamicQueriesDp) {
                private static final long serialVersionUID = 1L;
                @Override
                public void populateItem(final Item<TicketQuery> item) {
                    final TicketQuery tq = item.getModelObject();
                    Component swatch = new Label("swatch", "&nbsp;").setEscapeModelStrings(false);
                    if (StringUtils.isEmpty(tq.color)) {
                        // calculate a color
                        tq.color = StringUtils.getColor(tq.name);
                    }
                    String background = MessageFormat.format("background-color:{0};", tq.color);
                    swatch.add(new SimpleAttributeModifier("style", background));
                    item.add(swatch);
                    if (activeQuery.contains(tq.query)) {
                        // selected
                        String q = QueryBuilder.q(activeQuery).remove(tq.query).build();
                        PageParameters params = queryParameters(q, milestoneParam, statiiParam, assignedToParam, sortBy, desc, 1);
                        item.add(new LinkPanel("link", "active", tq.name, TicketsPage.class, params).setRenderBodyOnly(true));
                        Label checked = new Label("checked");
                        WicketUtils.setCssClass(checked, "iconic-o-x");
                        item.add(checked);
                        item.add(new SimpleAttributeModifier("style", background));
                    } else {
                        // unselected
                        String q = QueryBuilder.q(queryParam).toSubquery().and(tq.query).build();
                        PageParameters params = queryParameters(q, milestoneParam, statiiParam, assignedToParam, sortBy, desc, 1);
                        item.add(new LinkPanel("link", null, tq.name, TicketsPage.class, params).setRenderBodyOnly(true));
                        item.add(new Label("checked").setVisible(false));
                    }
                }
            };
            fragment.add(dynamicQueriesList);
            add(fragment);
        }
        // states
        if (ArrayUtils.isEmpty(statiiParam)) {
            add(new Label("selectedStatii", getString("gb.all")));
        } else {
            add(new Label("selectedStatii", StringUtils.flattenStrings(Arrays.asList(statiiParam), ",")));
        }
        add(new BookmarkablePageLink<Void>("openTickets", TicketsPage.class, queryParameters(queryParam, milestoneParam, openStatii, assignedToParam, sortBy, desc, 1)));
        add(new BookmarkablePageLink<Void>("closedTickets", TicketsPage.class, queryParameters(queryParam, milestoneParam, closedStatii, assignedToParam, sortBy, desc, 1)));
        add(new BookmarkablePageLink<Void>("allTickets", TicketsPage.class, queryParameters(queryParam, milestoneParam, null, assignedToParam, sortBy, desc, 1)));
        // by status
        List<Status> statii = Arrays.asList(Status.values());
        ListDataProvider<Status> resolutionsDp = new ListDataProvider<Status>(statii);
        DataView<Status> statiiLinks = new DataView<Status>("statii", resolutionsDp) {
            private static final long serialVersionUID = 1L;
            @Override
            public void populateItem(final Item<Status> item) {
                final Status status = item.getModelObject();
                PageParameters p = queryParameters(queryParam, milestoneParam, new String [] { status.name().toLowerCase() }, assignedToParam, sortBy, desc, 1);
                String css = getStatusClass(status);
                item.add(new LinkPanel("statusLink", css, status.toString(), TicketsPage.class, p).setRenderBodyOnly(true));
            }
        };
        add(statiiLinks);
        // responsible filter
        List<TicketResponsible> responsibles = new ArrayList<TicketResponsible>();
        for (RegistrantAccessPermission perm : app().repositories().getUserAccessPermissions(getRepositoryModel())) {
            if (perm.permission.atLeast(AccessPermission.PUSH)) {
                UserModel u = app().users().getUserModel(perm.registrant);
                responsibles.add(new TicketResponsible(u));
            }
        }
        Collections.sort(responsibles);
        responsibles.add(0, any);
        TicketResponsible currentResponsible = null;
        for (TicketResponsible u : responsibles) {
            if (u.username.equals(assignedToParam)) {
                currentResponsible = u;
                break;
            }
        }
        add(new Label("currentResponsible", currentResponsible == null ? "" : currentResponsible.displayname));
        ListDataProvider<TicketResponsible> responsibleDp = new ListDataProvider<TicketResponsible>(responsibles);
        DataView<TicketResponsible> responsibleMenu = new DataView<TicketResponsible>("responsible", responsibleDp) {
            private static final long serialVersionUID = 1L;
            @Override
            public void populateItem(final Item<TicketResponsible> item) {
                final TicketResponsible u = item.getModelObject();
                PageParameters params = queryParameters(queryParam, milestoneParam, statiiParam, u.username, sortBy, desc, 1);
                item.add(new LinkPanel("responsibleLink", null, u.displayname, TicketsPage.class, params).setRenderBodyOnly(true));
            }
        };
        add(responsibleMenu);
        PageParameters resetResponsibleParams = queryParameters(queryParam, milestoneParam, statiiParam, null, sortBy, desc, 1);
        add(new BookmarkablePageLink<Void>("resetResponsible", TicketsPage.class, resetResponsibleParams));
        List<TicketSort> sortChoices = new ArrayList<TicketSort>();
        sortChoices.add(new TicketSort(getString("gb.sortNewest"), Lucene.created.name(), true));
        sortChoices.add(new TicketSort(getString("gb.sortOldest"), Lucene.created.name(), false));
        sortChoices.add(new TicketSort(getString("gb.sortMostRecentlyUpdated"), Lucene.updated.name(), true));
        sortChoices.add(new TicketSort(getString("gb.sortLeastRecentlyUpdated"), Lucene.updated.name(), false));
        sortChoices.add(new TicketSort(getString("gb.sortMostComments"), Lucene.comments.name(), true));
        sortChoices.add(new TicketSort(getString("gb.sortLeastComments"), Lucene.comments.name(), false));
        sortChoices.add(new TicketSort(getString("gb.sortMostPatchsetRevisions"), Lucene.patchsets.name(), true));
        sortChoices.add(new TicketSort(getString("gb.sortLeastPatchsetRevisions"), Lucene.patchsets.name(), false));
        sortChoices.add(new TicketSort(getString("gb.sortMostVotes"), Lucene.votes.name(), true));
        sortChoices.add(new TicketSort(getString("gb.sortLeastVotes"), Lucene.votes.name(), false));
        TicketSort currentSort = sortChoices.get(0);
        for (TicketSort ts : sortChoices) {
            if (ts.sortBy.equals(sortBy) && desc == ts.desc) {
                currentSort = ts;
                break;
            }
        }
        add(new Label("currentSort", currentSort.name));
        ListDataProvider<TicketSort> sortChoicesDp = new ListDataProvider<TicketSort>(sortChoices);
        DataView<TicketSort> sortMenu = new DataView<TicketSort>("sort", sortChoicesDp) {
            private static final long serialVersionUID = 1L;
            @Override
            public void populateItem(final Item<TicketSort> item) {
                final TicketSort ts = item.getModelObject();
                PageParameters params = queryParameters(queryParam, milestoneParam, statiiParam, assignedToParam, ts.sortBy, ts.desc, 1);
                item.add(new LinkPanel("sortLink", null, ts.name, TicketsPage.class, params).setRenderBodyOnly(true));
            }
        };
        add(sortMenu);
        // paging links
        buildPager(queryParam, milestoneParam, statiiParam, assignedToParam, sortBy, desc, page, pageSize, results.size(), totalResults);
        ListDataProvider<QueryResult> resultsDataProvider = new ListDataProvider<QueryResult>(results);
        DataView<QueryResult> ticketsView = new DataView<QueryResult>("ticket", resultsDataProvider) {
            private static final long serialVersionUID = 1L;
            @Override
            public void populateItem(final Item<QueryResult> item) {
                final QueryResult ticket = item.getModelObject();
                item.add(getStateIcon("state", ticket.type, ticket.status));
                item.add(new Label("id", "" + ticket.number));
                UserModel creator = app().users().getUserModel(ticket.createdBy);
                if (creator != null) {
                    item.add(new LinkPanel("createdBy", null, creator.getDisplayName(),
                        UserPage.class, WicketUtils.newUsernameParameter(ticket.createdBy)));
                } else {
                    item.add(new Label("createdBy", ticket.createdBy));
                }
                item.add(WicketUtils.createDateLabel("createDate", ticket.createdAt, GitBlitWebSession
                        .get().getTimezone(), getTimeUtils(), false));
                if (ticket.updatedAt == null) {
                    item.add(new Label("updated").setVisible(false));
                } else {
                    Fragment updated = new Fragment("updated", "updatedFragment", this);
                    UserModel updater = app().users().getUserModel(ticket.updatedBy);
                    if (updater != null) {
                        updated.add(new LinkPanel("updatedBy", null, updater.getDisplayName(),
                                UserPage.class, WicketUtils.newUsernameParameter(ticket.updatedBy)));
                    } else {
                        updated.add(new Label("updatedBy", ticket.updatedBy));
                    }
                    updated.add(WicketUtils.createDateLabel("updateDate", ticket.updatedAt, GitBlitWebSession
                            .get().getTimezone(), getTimeUtils(), false));
                    item.add(updated);
                }
                item.add(new LinkPanel("title", "list subject", StringUtils.trimString(
                        ticket.title, Constants.LEN_SHORTLOG), TicketsPage.class, newTicketParameter(ticket)));
                ListDataProvider<String> labelsProvider = new ListDataProvider<String>(ticket.getLabels());
                DataView<String> labelsView = new DataView<String>("labels", labelsProvider) {
                    private static final long serialVersionUID = 1L;
                    @Override
                    public void populateItem(final Item<String> labelItem) {
                        String content = messageProcessor().processPlainCommitMessage(getRepository(), repositoryName, labelItem.getModelObject());
                        Label label = new Label("label", content);
                        label.setEscapeModelStrings(false);
                        TicketLabel tLabel = app().tickets().getLabel(getRepositoryModel(), labelItem.getModelObject());
                        String background = MessageFormat.format("background-color:{0};", tLabel.color);
                        label.add(new SimpleAttributeModifier("style", background));
                        labelItem.add(label);
                    }
                };
                item.add(labelsView);
                if (StringUtils.isEmpty(ticket.responsible)) {
                    item.add(new Label("responsible").setVisible(false));
                } else {
                    UserModel responsible = app().users().getUserModel(ticket.responsible);
                    if (responsible == null) {
                        responsible = new UserModel(ticket.responsible);
                    }
                    GravatarImage avatar = new GravatarImage("responsible", responsible.getDisplayName(),
                            responsible.emailAddress, null, 16, true);
                    avatar.setTooltip(getString("gb.responsible") + ": " + responsible.getDisplayName());
                    item.add(avatar);
                }
                // votes indicator
                Label v = new Label("votes", "" + ticket.votesCount);
                WicketUtils.setHtmlTooltip(v, getString("gb.votes"));
                item.add(v.setVisible(ticket.votesCount > 0));
                // watching indicator
                item.add(new Label("watching").setVisible(ticket.isWatching(GitBlitWebSession.get().getUsername())));
                // status indicator
                String css = getLozengeClass(ticket.status, true);
                Label l = new Label("status", ticket.status.toString());
                WicketUtils.setCssClass(l, css);
                item.add(l);
                // add the ticket indicators/icons
                List<Indicator> indicators = new ArrayList<Indicator>();
                // comments
                if (ticket.commentsCount > 0) {
                    int count = ticket.commentsCount;
                    String pattern = "gb.nComments";
                    if (count == 1) {
                        pattern = "gb.oneComment";
                    }
                    indicators.add(new Indicator("fa fa-comment", count, pattern));
                }
                // participants
                if (!ArrayUtils.isEmpty(ticket.participants)) {
                    int count = ticket.participants.size();
                    if (count > 1) {
                        String pattern = "gb.nParticipants";
                        indicators.add(new Indicator("fa fa-user", count, pattern));
                    }
                }
                // attachments
                if (!ArrayUtils.isEmpty(ticket.attachments)) {
                    int count = ticket.attachments.size();
                    String pattern = "gb.nAttachments";
                    if (count == 1) {
                        pattern = "gb.oneAttachment";
                    }
                    indicators.add(new Indicator("fa fa-file", count, pattern));
                }
                // patchset revisions
                if (ticket.patchset != null) {
                    int count = ticket.patchset.commits;
                    String pattern = "gb.nCommits";
                    if (count == 1) {
                        pattern = "gb.oneCommit";
                    }
                    indicators.add(new Indicator("fa fa-code", count, pattern));
                }
                // milestone
                if (!StringUtils.isEmpty(ticket.milestone)) {
                    indicators.add(new Indicator("fa fa-bullseye", ticket.milestone));
                }
                ListDataProvider<Indicator> indicatorsDp = new ListDataProvider<Indicator>(indicators);
                DataView<Indicator> indicatorsView = new DataView<Indicator>("indicators", indicatorsDp) {
                    private static final long serialVersionUID = 1L;
                    @Override
                    public void populateItem(final Item<Indicator> item) {
                        Indicator indicator = item.getModelObject();
                        String tooltip = indicator.getTooltip();
                        Label icon = new Label("icon");
                        WicketUtils.setCssClass(icon, indicator.css);
                        item.add(icon);
                        if (indicator.count > 0) {
                            Label count = new Label("count", "" + indicator.count);
                            item.add(count.setVisible(!StringUtils.isEmpty(tooltip)));
                        } else {
                            item.add(new Label("count").setVisible(false));
                        }
                        WicketUtils.setHtmlTooltip(item, tooltip);
                    }
                };
                item.add(indicatorsView);
            }
        };
        add(ticketsView);
        DataView<TicketMilestone> milestonesList = new DataView<TicketMilestone>("milestoneList", milestonesDp) {
            private static final long serialVersionUID = 1L;
            @Override
            public void populateItem(final Item<TicketMilestone> item) {
                final TicketMilestone tm = item.getModelObject();
                item.add(new Label("milestoneName", tm.name));
                item.add(new Label("milestoneState", tm.status.name()));
                item.add(new Label("milestoneDue", tm.due == null ? getString("gb.notSpecified") : tm.due.toString()));
            }
        };
        add(milestonesList);
    }
    protected PageParameters queryParameters(
            String query,
            String milestone,
            String[] states,
            String assignedTo,
            String sort,
            boolean descending,
            int page) {
        PageParameters params = WicketUtils.newRepositoryParameter(repositoryName);
        if (!StringUtils.isEmpty(query)) {
            params.add("q", query);
        }
        if (!StringUtils.isEmpty(milestone)) {
            params.add(Lucene.milestone.name(), milestone);
        }
        if (!ArrayUtils.isEmpty(states)) {
            for (String state : states) {
                params.add(Lucene.status.name(), state);
            }
        }
        if (!StringUtils.isEmpty(assignedTo)) {
            params.add(Lucene.responsible.name(), assignedTo);
        }
        if (!StringUtils.isEmpty(sort)) {
            params.add("sort", sort);
        }
        if (!descending) {
            params.add("direction", "asc");
        }
        if (page > 1) {
            params.add("pg", "" + page);
        }
        return params;
    }
    protected PageParameters newTicketParameter(QueryResult ticket) {
        return WicketUtils.newObjectParameter(repositoryName, "" + ticket.number);
    }
    @Override
    protected String getPageName() {
        return getString("gb.tickets");
    }
    protected void buildPager(
            final String query,
            final String milestone,
            final String [] states,
            final String assignedTo,
            final String sort,
            final boolean desc,
            final int page,
            int pageSize,
            int count,
            int total) {
        boolean showNav = total > (2 * pageSize);
        boolean allowPrev = page > 1;
        boolean allowNext = (pageSize * (page - 1) + count) < total;
        add(new BookmarkablePageLink<Void>("prevLink", TicketsPage.class, queryParameters(query, milestone, states, assignedTo, sort, desc, page - 1)).setEnabled(allowPrev).setVisible(showNav));
        add(new BookmarkablePageLink<Void>("nextLink", TicketsPage.class, queryParameters(query, milestone, states, assignedTo, sort, desc, page + 1)).setEnabled(allowNext).setVisible(showNav));
        if (total <= pageSize) {
            add(new Label("pageLink").setVisible(false));
            return;
        }
        // determine page numbers to display
        int pages = count == 0 ? 0 : ((total / pageSize) + (total % pageSize == 0 ? 0 : 1));
        // preferred number of pagelinks
        int segments = 5;
        if (pages < segments) {
            // not enough data for preferred number of page links
            segments = pages;
        }
        int minpage = Math.min(Math.max(1, page - 2), pages - (segments - 1));
        int maxpage = Math.min(pages, minpage + (segments - 1));
        List<Integer> sequence = new ArrayList<Integer>();
        for (int i = minpage; i <= maxpage; i++) {
            sequence.add(i);
        }
        ListDataProvider<Integer> pagesDp = new ListDataProvider<Integer>(sequence);
        DataView<Integer> pagesView = new DataView<Integer>("pageLink", pagesDp) {
            private static final long serialVersionUID = 1L;
            @Override
            public void populateItem(final Item<Integer> item) {
                final Integer i = item.getModelObject();
                LinkPanel link = new LinkPanel("page", null, "" + i, TicketsPage.class, queryParameters(query, milestone, states, assignedTo, sort, desc, i));
                link.setRenderBodyOnly(true);
                if (i == page) {
                    WicketUtils.setCssClass(item, "active");
                }
                item.add(link);
            }
        };
        add(pagesView);
    }
    private class Indicator implements Serializable {
        private static final long serialVersionUID = 1L;
        final String css;
        final int count;
        final String tooltip;
        Indicator(String css, String tooltip) {
            this.css = css;
            this.tooltip = tooltip;
            this.count = 0;
        }
        Indicator(String css, int count, String pattern) {
            this.css = css;
            this.count = count;
            this.tooltip = StringUtils.isEmpty(pattern) ? "" : MessageFormat.format(getString(pattern), count);
        }
        String getTooltip() {
            return tooltip;
        }
    }
    private class TicketQuery implements Serializable, Comparable<TicketQuery> {
        private static final long serialVersionUID = 1L;
        final String name;
        final String query;
        String color;
        TicketQuery(String name, String query) {
            this.name = name;
            this.query = query;
        }
        TicketQuery color(String value) {
            this.color = value;
            return this;
        }
        @Override
        public boolean equals(Object o) {
            if (o instanceof TicketQuery) {
                return ((TicketQuery) o).query.equals(query);
            }
            return false;
        }
        @Override
        public int hashCode() {
            return query.hashCode();
        }
        @Override
        public int compareTo(TicketQuery o) {
            return query.compareTo(o.query);
        }
    }
    private class TicketSort implements Serializable {
        private static final long serialVersionUID = 1L;
        final String name;
        final String sortBy;
        final boolean desc;
        TicketSort(String name, String sortBy, boolean desc) {
            this.name = name;
            this.sortBy = sortBy;
            this.desc = desc;
        }
    }
    private class TicketSearchForm extends SessionlessForm<Void> implements Serializable {
        private static final long serialVersionUID = 1L;
        private final String repositoryName;
        private final IModel<String> searchBoxModel;;
        public TicketSearchForm(String id, String repositoryName, String text) {
            super(id, TicketsPage.this.getClass(), TicketsPage.this.getPageParameters());
            this.repositoryName = repositoryName;
            this.searchBoxModel = new Model<String>(text == null ? "" : text);
            TextField<String> searchBox = new TextField<String>("ticketSearchBox", searchBoxModel);
            add(searchBox);
        }
        void setTranslatedAttributes() {
            WicketUtils.setHtmlTooltip(get("ticketSearchBox"),
                    MessageFormat.format(getString("gb.searchTicketsTooltip"), repositoryName));
            WicketUtils.setInputPlaceholder(get("ticketSearchBox"), getString("gb.searchTickets"));
        }
        @Override
        public void onSubmit() {
            String searchString = searchBoxModel.getObject();
            if (StringUtils.isEmpty(searchString)) {
                // redirect to self to avoid wicket page update bug
                String absoluteUrl = getCanonicalUrl();
                getRequestCycle().setRequestTarget(new RedirectRequestTarget(absoluteUrl));
                return;
            }
            // use an absolute url to workaround Wicket-Tomcat problems with
            // mounted url parameters (issue-111)
            PageParameters params = WicketUtils.newRepositoryParameter(repositoryName);
            params.add("s", searchString);
            String absoluteUrl = getCanonicalUrl(TicketsPage.class, params);
            getRequestCycle().setRequestTarget(new RedirectRequestTarget(absoluteUrl));
        }
    }
}
src/main/java/com/gitblit/wicket/pages/propose_git.md
New file
@@ -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}
src/main/java/com/gitblit/wicket/pages/propose_pt.md
New file
@@ -0,0 +1,5 @@
    git clone ${url}
    cd ${repo}
    pt start ${ticketId}
    ...
    pt propose
src/main/java/com/gitblit/wicket/panels/CommentPanel.html
New file
@@ -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>
src/main/java/com/gitblit/wicket/panels/CommentPanel.java
New file
@@ -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);
        }
    }
}
src/main/java/com/gitblit/wicket/panels/DigestsPanel.java
@@ -1,263 +1,276 @@
/*
 * 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.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;
                boolean isTag = false;
                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 {
                    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 {
                    // 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;
    }
}
/*
 * 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;
    }
}
src/main/java/com/gitblit/wicket/panels/GravatarImage.java
@@ -1,70 +1,74 @@
/*
 * 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));
    }
/*
 * 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);
    }
}
src/main/java/com/gitblit/wicket/panels/MarkdownTextArea.java
New file
@@ -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);
        }
    }
}
src/main/java/com/gitblit/wicket/panels/ReflogPanel.html
@@ -12,7 +12,7 @@
        <td class="icon hidden-phone"><i wicket:id="changeIcon"></i></td>
        <td style="padding-left: 7px;vertical-align:middle;">
            <div>
                <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>
                <span class="when" wicket:id="whenChanged"></span> <span wicket:id="refRewind" class="aui-lozenge aui-lozenge-error">[rewind]</span>
            </div>
            <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>
        </td>
@@ -26,7 +26,7 @@
                        <td class="hidden-phone hidden-tablet" style="vertical-align:top;padding-left:7px;"><span wicket:id="commitAuthor"></span></td>
                        <td style="vertical-align:top;"><span wicket:id="hashLink" style="padding-left: 5px;">[hash link]</span></td>
                        <td style="vertical-align:top;padding-left:5px;"><img wicket:id="commitIcon" /></td>
                        <td style="vertical-align:top;">
                        <td style="vertical-align:top;">
                            <span wicket:id="commitShortMessage">[commit short message]</span>
                        </td>
                    </tr>
src/main/java/com/gitblit/wicket/panels/ReflogPanel.java
@@ -1,313 +1,325 @@
/*
 * 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.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;
                boolean isTag = false;
                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 {
                    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 {
                    // 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;
    }
}
/*
 * 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;
    }
}
src/main/java/com/gitblit/wicket/panels/RefsPanel.java
@@ -25,7 +25,6 @@
import org.apache.wicket.Component;
import org.apache.wicket.markup.html.WebPage;
import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.panel.Panel;
import org.apache.wicket.markup.repeater.Item;
import org.apache.wicket.markup.repeater.data.DataView;
import org.apache.wicket.markup.repeater.data.ListDataProvider;
@@ -34,13 +33,15 @@
import com.gitblit.Constants;
import com.gitblit.models.RefModel;
import com.gitblit.models.RepositoryModel;
import com.gitblit.utils.StringUtils;
import com.gitblit.wicket.WicketUtils;
import com.gitblit.wicket.pages.CommitPage;
import com.gitblit.wicket.pages.LogPage;
import com.gitblit.wicket.pages.TagPage;
import com.gitblit.wicket.pages.TicketsPage;
public class RefsPanel extends Panel {
public class RefsPanel extends BasePanel {
    private static final long serialVersionUID = 1L;
@@ -88,6 +89,8 @@
            }
        }
        final boolean shouldBreak = remoteCount < refs.size();
        RepositoryModel repository = app().repositories().getRepositoryModel(repositoryName);
        final boolean hasTickets = app().tickets().hasTickets(repository);
        ListDataProvider<RefModel> refsDp = new ListDataProvider<RefModel>(refs);
        DataView<RefModel> refsView = new DataView<RefModel>("ref", refsDp) {
@@ -103,7 +106,13 @@
                Class<? extends WebPage> linkClass = CommitPage.class;
                String cssClass = "";
                String tooltip = "";
                if (name.startsWith(Constants.R_HEADS)) {
                if (name.startsWith(Constants.R_TICKET)) {
                    // Gitblit ticket ref
                    objectid = name.substring(Constants.R_TICKET.length());
                    name = name.substring(Constants.R_HEADS.length());
                    linkClass = TicketsPage.class;
                    cssClass = "localBranch";
                } else if (name.startsWith(Constants.R_HEADS)) {
                    // local branch
                    linkClass = LogPage.class;
                    name = name.substring(Constants.R_HEADS.length());
@@ -113,13 +122,23 @@
                    linkClass = LogPage.class;
                    cssClass = "headRef";
                } else if (name.startsWith(Constants.R_CHANGES)) {
                    // Gerrit change ref
                    // Gitblit change ref
                    name = name.substring(Constants.R_CHANGES.length());
                    // strip leading nn/ from nn/#####nn/ps = #####nn-ps
                    name = name.substring(name.indexOf('/') + 1).replace('/', '-');
                    String [] values = name.split("-");
                    // Gerrit change
                    tooltip = MessageFormat.format(getString("gb.reviewPatchset"), values[0], values[1]);
                    cssClass = "otherRef";
                } else if (name.startsWith(Constants.R_TICKETS_PATCHSETS)) {
                    // Gitblit patchset ref
                    name = name.substring(Constants.R_TICKETS_PATCHSETS.length());
                    // strip leading nn/ from nn/#####nn/ps = #####nn-ps
                    name = name.substring(name.indexOf('/') + 1).replace('/', '-');
                    String [] values = name.split("-");
                    tooltip = MessageFormat.format(getString("gb.ticketPatchset"), values[0], values[1]);
                    linkClass = LogPage.class;
                    cssClass = "otherRef";
                } else if (name.startsWith(Constants.R_PULL)) {
                    // Pull Request ref
                    String num = name.substring(Constants.R_PULL.length());
src/main/java/pt.cmd
New file
@@ -0,0 +1 @@
@python %~dp0pt.py %1 %2 %3 %4 %5 %6 %7 %8 %9
src/main/java/pt.py
New file
@@ -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)
src/main/java/pt.txt
New file
@@ -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>]
src/main/resources/barnum_32x32.png
src/main/resources/gitblit.css
@@ -38,11 +38,53 @@
    font-weight: bold;
}
.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 {
    /* override for a links that look like bootstrap buttons */
    vertical-align: text-bottom;
}
.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 {
    margin-top: 10px;
    margin-bottom: 10px;
@@ -137,7 +179,8 @@
    color: #ffffff !important;
}
.nav-pills > .active > a, .nav-pills > .active > a:hover {
.nav-pills > .active > a, .nav-pills > .active > a:hover,
.nav-list > .active > a, .nav-list > .active > a:hover {
    color: #fff;
    background-color: #002060;
}
@@ -520,6 +563,24 @@
    text-align: left;    
}
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 {
    padding: 15px;
    background-color: #fbfbfb;
@@ -532,6 +593,30 @@
    font-size: 144px;
}
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 {
    overflow: hidden;
}
@@ -619,6 +704,7 @@
    border: 1px solid #ccc;
    color: #ccc;
    font-weight:bold;
    display: inline-block;
}
.diffstat-inline {
@@ -650,7 +736,207 @@
.diffstat-delete {
    color: #B9583B; 
}
.patch-group {
    margin-bottom: 0px;
    border: 1px solid #ccc;
    background-color: #fbfbfb;
}
.patch-group .accordion-inner {
    padding: 0px;
}
.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;
}
.ticket-text-editor {
    height:7em;
    border:0px;
    border-radius: 0px;
    border-top:1px solid #ccc;
    margin-bottom:0px;
    padding:4px;
    background-color:#ffffff;
    box-shadow: none;
}
.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 {
    color: #888;
}
@@ -727,6 +1013,12 @@
    color: #008000;
}
span.highlight {
    background-color: rgb(255, 255, 100);
    color: black;
    padding: 0px 2px;
}
span.link {
    color: #888;
}
@@ -775,11 +1067,17 @@
img.gravatar {
    background-color: #ffffff;
    border: 1px solid #ddd;
    /*border: 1px solid #ddd;*/
    border-radius: 5px;
    padding: 2px;
}
img.gravatar-round {
    background-color: #ffffff;
    border: 1px solid #ccc;
    border-radius: 100%;
}
img.navbarGravatar {
    border: 1px solid #fff;
}
@@ -1157,7 +1455,7 @@
    text-align: right;
}
table.plain, table.summary {
table.plain, table.summary, table.ticket {
    width: 0 !important;
    border: 0;
}
@@ -1168,11 +1466,16 @@
    border: 0;
}
table.ticket th, table.ticket td {
    padding: 1px 3px;
    border: 0;
}
table.summary {
    margin: 0px;
}
table.summary th {
table.summary th, table.ticket th {
    color: #999;
    padding-right: 10px;
    text-align: right;
@@ -1662,4 +1965,105 @@
    vertical-align: top;
    border-top: 1px solid #ccc;
    padding:5px;
}
.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;
}
src/site/design.mkd
@@ -53,6 +53,7 @@
- [libpam4j](https://github.com/kohsuke/libpam4j) (MIT)
- [commons-codec](http://commons.apache.org/proper/commons-codec) (Apache 2.0)
- [pegdown](https://github.com/sirthias/pegdown) (Apache 2.0)
- [jedis](https://github.com/xetorthio/jedis) (MIT)
### Other Build Dependencies
- [Fancybox image viewer](http://fancybox.net) (MIT and GPL dual-licensed)
src/site/tickets_barnum.mkd
New file
@@ -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.
src/site/tickets_overview.mkd
New file
@@ -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.
src/site/tickets_setup.mkd
New file
@@ -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.
src/site/tickets_using.mkd
New file
@@ -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.
src/test/java/com/gitblit/tests/BranchTicketServiceTest.java
New file
@@ -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;
    }
}
src/test/java/com/gitblit/tests/FileTicketServiceTest.java
New file
@@ -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;
    }
}
src/test/java/com/gitblit/tests/GitBlitSuite.java
@@ -63,7 +63,8 @@
        GitBlitTest.class, FederationTests.class, RpcTests.class, GitServletTest.class, GitDaemonTest.class,
        GroovyScriptTest.class, LuceneExecutorTest.class, RepositoryModelTest.class,
        FanoutServiceTest.class, Issue0259Test.class, Issue0271Test.class, HtpasswdAuthenticationTest.class,
        ModelUtilsTest.class, JnaUtilsTest.class, LdapSyncServiceTest.class })
        ModelUtilsTest.class, JnaUtilsTest.class, LdapSyncServiceTest.class, FileTicketServiceTest.class,
        BranchTicketServiceTest.class, RedisTicketServiceTest.class  })
public class GitBlitSuite {
    public static final File BASEFOLDER = new File("data");
@@ -106,6 +107,11 @@
        return getRepository("test/gitective.git");
    }
    public static Repository getTicketsTestRepository() {
        JGitUtils.createRepository(REPOSITORIES, "gb-tickets.git").close();
        return getRepository("gb-tickets.git");
    }
    private static Repository getRepository(String name) {
        try {
            File gitDir = FileKey.resolve(new File(REPOSITORIES, name), FS.DETECTED);
src/test/java/com/gitblit/tests/RedisTicketServiceTest.java
New file
@@ -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;
    }
}
src/test/java/com/gitblit/tests/TicketServiceTest.java
New file
@@ -0,0 +1,351 @@
/*
 * 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 java.io.File;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.commons.io.FileUtils;
import org.bouncycastle.util.Arrays;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import com.gitblit.IStoredSettings;
import com.gitblit.Keys;
import com.gitblit.models.Mailing;
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.models.TicketModel.Type;
import com.gitblit.tests.mock.MemorySettings;
import com.gitblit.tickets.ITicketService;
import com.gitblit.tickets.ITicketService.TicketFilter;
import com.gitblit.tickets.QueryResult;
import com.gitblit.tickets.TicketIndexer.Lucene;
import com.gitblit.tickets.TicketLabel;
import com.gitblit.tickets.TicketMilestone;
import com.gitblit.tickets.TicketNotifier;
import com.gitblit.utils.JGitUtils;
/**
 * Tests the mechanics of Gitblit ticket management.
 *
 * @author James Moger
 *
 */
public abstract class TicketServiceTest extends GitblitUnitTest {
    private ITicketService service;
    protected abstract RepositoryModel getRepository();
    protected abstract ITicketService getService(boolean deleteAll) throws Exception;
    protected IStoredSettings getSettings(boolean deleteAll) throws Exception {
        File dir = new File(GitBlitSuite.REPOSITORIES, getRepository().name);
        if (deleteAll) {
            FileUtils.deleteDirectory(dir);
            JGitUtils.createRepository(GitBlitSuite.REPOSITORIES, getRepository().name).close();
        }
        File luceneDir = new File(dir, "tickets/lucene");
        luceneDir.mkdirs();
        Map<String, Object> map = new HashMap<String, Object>();
        map.put(Keys.git.repositoriesFolder, GitBlitSuite.REPOSITORIES.getAbsolutePath());
        map.put(Keys.tickets.indexFolder, luceneDir.getAbsolutePath());
        IStoredSettings settings = new MemorySettings(map);
        return settings;
    }
    @Before
    public void setup() throws Exception {
        service = getService(true);
    }
    @After
    public void cleanup() {
        service.stop();
    }
    @Test
    public void testLifecycle() throws Exception {
        // create and insert a ticket
        Change c1 = newChange("testCreation() " + Long.toHexString(System.currentTimeMillis()));
        TicketModel ticket = service.createTicket(getRepository(), c1);
        assertTrue(ticket.number > 0);
        // retrieve ticket and compare
        TicketModel constructed = service.getTicket(getRepository(), ticket.number);
        compare(ticket, constructed);
        assertEquals(1, constructed.changes.size());
        // C1: create the ticket
        int changeCount = 0;
        c1 = newChange("testUpdates() " + Long.toHexString(System.currentTimeMillis()));
        ticket = service.createTicket(getRepository(), c1);
        assertTrue(ticket.number > 0);
        changeCount++;
        constructed = service.getTicket(getRepository(), ticket.number);
        compare(ticket, constructed);
        assertEquals(1, constructed.changes.size());
        // C2: set owner
        Change c2 = new Change("C2");
        c2.comment("I'll fix this");
        c2.setField(Field.responsible, c2.author);
        constructed = service.updateTicket(getRepository(), ticket.number, c2);
        assertNotNull(constructed);
        assertEquals(2, constructed.changes.size());
        assertEquals(c2.author, constructed.responsible);
        changeCount++;
        // C3: add a note
        Change c3 = new Change("C3");
        c3.comment("yeah, this is working");
        constructed = service.updateTicket(getRepository(), ticket.number, c3);
        assertNotNull(constructed);
        assertEquals(3, constructed.changes.size());
        changeCount++;
        if (service.supportsAttachments()) {
            // C4: add attachment
            Change c4 = new Change("C4");
            Attachment a = newAttachment();
            c4.addAttachment(a);
            constructed = service.updateTicket(getRepository(), ticket.number, c4);
            assertNotNull(constructed);
            assertTrue(constructed.hasAttachments());
            Attachment a1 = service.getAttachment(getRepository(), ticket.number, a.name);
            assertEquals(a.content.length, a1.content.length);
            assertTrue(Arrays.areEqual(a.content, a1.content));
            changeCount++;
        }
        // C5: close the issue
        Change c5 = new Change("C5");
        c5.comment("closing issue");
        c5.setField(Field.status, Status.Resolved);
        constructed = service.updateTicket(getRepository(), ticket.number, c5);
        assertNotNull(constructed);
        changeCount++;
        assertTrue(constructed.isClosed());
        assertEquals(changeCount, constructed.changes.size());
        List<TicketModel> allTickets = service.getTickets(getRepository());
        List<TicketModel> openTickets = service.getTickets(getRepository(), new TicketFilter() {
            @Override
            public boolean accept(TicketModel ticket) {
                return ticket.isOpen();
            }
        });
        List<TicketModel> closedTickets = service.getTickets(getRepository(), new TicketFilter() {
            @Override
            public boolean accept(TicketModel ticket) {
                return ticket.isClosed();
            }
        });
        assertTrue(allTickets.size() > 0);
        assertEquals(1, openTickets.size());
        assertEquals(1, closedTickets.size());
        // build a new Lucene index
        service.reindex(getRepository());
        List<QueryResult> hits = service.searchFor(getRepository(), "working", 1, 10);
        assertEquals(1, hits.size());
        // reindex a ticket
        ticket = allTickets.get(0);
        Change change = new Change("reindex");
        change.comment("this is a test of reindexing a ticket");
        service.updateTicket(getRepository(), ticket.number, change);
        ticket = service.getTicket(getRepository(), ticket.number);
        hits = service.searchFor(getRepository(), "reindexing", 1, 10);
        assertEquals(1, hits.size());
        service.stop();
        service = getService(false);
        // Lucene field query
        List<QueryResult> results = service.queryFor(Lucene.status.matches(Status.New.name()), 1, 10, Lucene.created.name(), true);
        assertEquals(1, results.size());
        assertTrue(results.get(0).title.startsWith("testCreation"));
        // Lucene field query
        results = service.queryFor(Lucene.status.matches(Status.Resolved.name()), 1, 10, Lucene.created.name(), true);
        assertEquals(1, results.size());
        assertTrue(results.get(0).title.startsWith("testUpdates"));
        // delete all tickets
        for (TicketModel aTicket : allTickets) {
            assertTrue(service.deleteTicket(getRepository(), aTicket.number, "D"));
        }
    }
    @Test
    public void testChangeComment() throws Exception {
        // C1: create the ticket
        Change c1 = newChange("testChangeComment() " + Long.toHexString(System.currentTimeMillis()));
        TicketModel ticket = service.createTicket(getRepository(), c1);
        assertTrue(ticket.number > 0);
        assertTrue(ticket.changes.get(0).hasComment());
        ticket = service.updateComment(ticket, c1.comment.id, "E1", "I changed the comment");
        assertNotNull(ticket);
        assertTrue(ticket.changes.get(0).hasComment());
        assertEquals("I changed the comment", ticket.changes.get(0).comment.text);
        assertTrue(service.deleteTicket(getRepository(), ticket.number, "D"));
    }
    @Test
    public void testDeleteComment() throws Exception {
        // C1: create the ticket
        Change c1 = newChange("testDeleteComment() " + Long.toHexString(System.currentTimeMillis()));
        TicketModel ticket = service.createTicket(getRepository(), c1);
        assertTrue(ticket.number > 0);
        assertTrue(ticket.changes.get(0).hasComment());
        ticket = service.deleteComment(ticket, c1.comment.id, "D1");
        assertNotNull(ticket);
        assertEquals(1, ticket.changes.size());
        assertFalse(ticket.changes.get(0).hasComment());
        assertTrue(service.deleteTicket(getRepository(), ticket.number, "D"));
    }
    @Test
    public void testMilestones() throws Exception {
        service.createMilestone(getRepository(), "M1", "james");
        service.createMilestone(getRepository(), "M2", "frank");
        service.createMilestone(getRepository(), "M3", "joe");
        List<TicketMilestone> milestones = service.getMilestones(getRepository(), Status.Open);
        assertEquals("Unexpected open milestones count", 3, milestones.size());
        for (TicketMilestone milestone : milestones) {
            milestone.status = Status.Resolved;
            milestone.due = new Date();
            assertTrue("failed to update milestone " + milestone.name, service.updateMilestone(getRepository(), milestone, "ted"));
        }
        milestones = service.getMilestones(getRepository(), Status.Open);
        assertEquals("Unexpected open milestones count", 0, milestones.size());
        milestones = service.getMilestones(getRepository(), Status.Resolved);
        assertEquals("Unexpected resolved milestones count", 3, milestones.size());
        for (TicketMilestone milestone : milestones) {
            assertTrue("failed to delete milestone " + milestone.name, service.deleteMilestone(getRepository(), milestone.name, "lucifer"));
        }
    }
    @Test
    public void testLabels() throws Exception {
        service.createLabel(getRepository(), "L1", "james");
        service.createLabel(getRepository(), "L2", "frank");
        service.createLabel(getRepository(), "L3", "joe");
        List<TicketLabel> labels = service.getLabels(getRepository());
        assertEquals("Unexpected open labels count", 3, labels.size());
        for (TicketLabel label : labels) {
            label.color = "#ffff00";
            assertTrue("failed to update label " + label.name, service.updateLabel(getRepository(), label, "ted"));
        }
        labels = service.getLabels(getRepository());
        assertEquals("Unexpected labels count", 3, labels.size());
        for (TicketLabel label : labels) {
            assertTrue("failed to delete label " + label.name, service.deleteLabel(getRepository(), label.name, "lucifer"));
        }
    }
    private Change newChange(String summary) {
        Change change = new Change("C1");
        change.setField(Field.title, summary);
        change.setField(Field.body, "this is my description");
        change.setField(Field.labels, "helpdesk");
        change.comment("my comment");
        return change;
    }
    private Attachment newAttachment() {
        Attachment attachment = new Attachment("test1.txt");
        attachment.content = new byte[] { 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49,
                0x4a };
        return attachment;
    }
    private void compare(TicketModel ticket, TicketModel constructed) {
        assertEquals(ticket.number, constructed.number);
        assertEquals(ticket.createdBy, constructed.createdBy);
        assertEquals(ticket.responsible, constructed.responsible);
        assertEquals(ticket.title, constructed.title);
        assertEquals(ticket.body, constructed.body);
        assertEquals(ticket.created, constructed.created);
        assertTrue(ticket.hasLabel("helpdesk"));
    }
    @Test
    public void testNotifier() throws Exception {
        Change kernel = new Change("james");
        kernel.setField(Field.title, "Sample ticket");
        kernel.setField(Field.body, "this **is** my sample body\n\n- I hope\n- you really\n- *really* like it");
        kernel.setField(Field.status, Status.New);
        kernel.setField(Field.type, Type.Proposal);
        kernel.comment("this is a sample comment on a kernel change");
        Patchset patchset = new Patchset();
        patchset.insertions = 100;
        patchset.deletions = 10;
        patchset.number = 1;
        patchset.rev = 25;
        patchset.tip = "50f57913f816d04a16b7407134de5d8406421f37";
        kernel.patchset = patchset;
        TicketModel ticket = service.createTicket(getRepository(), 0L, kernel);
        Change merge = new Change("james");
        merge.setField(Field.mergeSha, patchset.tip);
        merge.setField(Field.mergeTo, "master");
        merge.setField(Field.status, Status.Merged);
        ticket = service.updateTicket(getRepository(), ticket.number, merge);
        ticket.repository = getRepository().name;
        TicketNotifier notifier = service.createNotifier();
        Mailing mailing = notifier.queueMailing(ticket);
        assertNotNull(mailing);
    }
}