Browse Source

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
tags/v1.4.0
James Moger 10 years ago
parent
commit
5e3521f849
89 changed files with 16359 additions and 692 deletions
  1. 2
    0
      .classpath
  2. 16
    1
      NOTICE
  3. 1
    0
      build.moxie
  4. 14
    0
      build.xml
  5. 22
    0
      gitblit.iml
  6. 6
    0
      releases.moxie
  7. 12
    0
      src/main/distrib/data/clientapps.json
  8. 70
    0
      src/main/distrib/data/gitblit.properties
  9. 15
    0
      src/main/distrib/linux/reindex-tickets.sh
  10. 13
    0
      src/main/distrib/win/reindex-tickets.cmd
  11. 15
    1
      src/main/java/WEB-INF/web.xml
  12. 7
    1
      src/main/java/com/gitblit/Constants.java
  13. 154
    0
      src/main/java/com/gitblit/GitBlit.java
  14. 183
    0
      src/main/java/com/gitblit/ReindexTickets.java
  15. 21
    0
      src/main/java/com/gitblit/client/EditRepositoryDialog.java
  16. 11
    0
      src/main/java/com/gitblit/git/GitblitReceivePack.java
  17. 9
    2
      src/main/java/com/gitblit/git/GitblitReceivePackFactory.java
  18. 324
    0
      src/main/java/com/gitblit/git/PatchsetCommand.java
  19. 1129
    0
      src/main/java/com/gitblit/git/PatchsetReceivePack.java
  20. 10
    0
      src/main/java/com/gitblit/manager/GitblitManager.java
  21. 8
    0
      src/main/java/com/gitblit/manager/IGitblit.java
  22. 12
    0
      src/main/java/com/gitblit/manager/RepositoryManager.java
  23. 11
    0
      src/main/java/com/gitblit/models/RepositoryModel.java
  24. 1286
    0
      src/main/java/com/gitblit/models/TicketModel.java
  25. 12
    0
      src/main/java/com/gitblit/models/UserModel.java
  26. 201
    0
      src/main/java/com/gitblit/servlet/PtServlet.java
  27. 799
    0
      src/main/java/com/gitblit/tickets/BranchTicketService.java
  28. 467
    0
      src/main/java/com/gitblit/tickets/FileTicketService.java
  29. 1088
    0
      src/main/java/com/gitblit/tickets/ITicketService.java
  30. 129
    0
      src/main/java/com/gitblit/tickets/NullTicketService.java
  31. 222
    0
      src/main/java/com/gitblit/tickets/QueryBuilder.java
  32. 114
    0
      src/main/java/com/gitblit/tickets/QueryResult.java
  33. 534
    0
      src/main/java/com/gitblit/tickets/RedisTicketService.java
  34. 657
    0
      src/main/java/com/gitblit/tickets/TicketIndexer.java
  35. 77
    0
      src/main/java/com/gitblit/tickets/TicketLabel.java
  36. 53
    0
      src/main/java/com/gitblit/tickets/TicketMilestone.java
  37. 617
    0
      src/main/java/com/gitblit/tickets/TicketNotifier.java
  38. 59
    0
      src/main/java/com/gitblit/tickets/TicketResponsible.java
  39. 175
    0
      src/main/java/com/gitblit/tickets/TicketSerializer.java
  40. 11
    0
      src/main/java/com/gitblit/tickets/commands.md
  41. 38
    0
      src/main/java/com/gitblit/tickets/email.css
  42. 207
    0
      src/main/java/com/gitblit/utils/JGitUtils.java
  43. 2
    2
      src/main/java/com/gitblit/utils/JsonUtils.java
  44. 4
    0
      src/main/java/com/gitblit/utils/MarkdownUtils.java
  45. 34
    6
      src/main/java/com/gitblit/utils/RefLogUtils.java
  46. 15
    0
      src/main/java/com/gitblit/wicket/GitBlitWebApp.java
  47. 139
    2
      src/main/java/com/gitblit/wicket/GitBlitWebApp.properties
  48. 25
    0
      src/main/java/com/gitblit/wicket/pages/BasePage.java
  49. 17
    14
      src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.html
  50. 7
    4
      src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.java
  51. 66
    0
      src/main/java/com/gitblit/wicket/pages/EditTicketPage.html
  52. 290
    0
      src/main/java/com/gitblit/wicket/pages/EditTicketPage.java
  53. 82
    0
      src/main/java/com/gitblit/wicket/pages/ExportTicketPage.java
  54. 66
    0
      src/main/java/com/gitblit/wicket/pages/NewTicketPage.html
  55. 202
    0
      src/main/java/com/gitblit/wicket/pages/NewTicketPage.java
  56. 21
    0
      src/main/java/com/gitblit/wicket/pages/NoTicketsPage.html
  57. 44
    0
      src/main/java/com/gitblit/wicket/pages/NoTicketsPage.java
  58. 1
    0
      src/main/java/com/gitblit/wicket/pages/RepositoryPage.html
  59. 18
    2
      src/main/java/com/gitblit/wicket/pages/RepositoryPage.java
  60. 124
    0
      src/main/java/com/gitblit/wicket/pages/TicketBasePage.java
  61. 577
    0
      src/main/java/com/gitblit/wicket/pages/TicketPage.html
  62. 1527
    0
      src/main/java/com/gitblit/wicket/pages/TicketPage.java
  63. 215
    0
      src/main/java/com/gitblit/wicket/pages/TicketsPage.html
  64. 878
    0
      src/main/java/com/gitblit/wicket/pages/TicketsPage.java
  65. 6
    0
      src/main/java/com/gitblit/wicket/pages/propose_git.md
  66. 5
    0
      src/main/java/com/gitblit/wicket/pages/propose_pt.md
  67. 29
    0
      src/main/java/com/gitblit/wicket/panels/CommentPanel.html
  68. 110
    0
      src/main/java/com/gitblit/wicket/panels/CommentPanel.java
  69. 276
    263
      src/main/java/com/gitblit/wicket/panels/DigestsPanel.java
  70. 73
    69
      src/main/java/com/gitblit/wicket/panels/GravatarImage.java
  71. 118
    0
      src/main/java/com/gitblit/wicket/panels/MarkdownTextArea.java
  72. 2
    2
      src/main/java/com/gitblit/wicket/panels/ReflogPanel.html
  73. 325
    313
      src/main/java/com/gitblit/wicket/panels/ReflogPanel.java
  74. 23
    4
      src/main/java/com/gitblit/wicket/panels/RefsPanel.java
  75. 1
    0
      src/main/java/pt.cmd
  76. 701
    0
      src/main/java/pt.py
  77. 49
    0
      src/main/java/pt.txt
  78. BIN
      src/main/resources/barnum_32x32.png
  79. 409
    5
      src/main/resources/gitblit.css
  80. 1
    0
      src/site/design.mkd
  81. 79
    0
      src/site/tickets_barnum.mkd
  82. 145
    0
      src/site/tickets_overview.mkd
  83. 119
    0
      src/site/tickets_setup.mkd
  84. 155
    0
      src/site/tickets_using.mkd
  85. 68
    0
      src/test/java/com/gitblit/tests/BranchTicketServiceTest.java
  86. 67
    0
      src/test/java/com/gitblit/tests/FileTicketServiceTest.java
  87. 7
    1
      src/test/java/com/gitblit/tests/GitBlitSuite.java
  88. 75
    0
      src/test/java/com/gitblit/tests/RedisTicketServiceTest.java
  89. 351
    0
      src/test/java/com/gitblit/tests/TicketServiceTest.java

+ 2
- 0
.classpath View File

@@ -70,6 +70,8 @@
<classpathentry kind="lib" path="ext/guava-13.0.1.jar" sourcepath="ext/src/guava-13.0.1.jar" />
<classpathentry kind="lib" path="ext/libpam4j-1.7.jar" sourcepath="ext/src/libpam4j-1.7.jar" />
<classpathentry kind="lib" path="ext/commons-codec-1.7.jar" sourcepath="ext/src/commons-codec-1.7.jar" />
<classpathentry kind="lib" path="ext/jedis-2.3.1.jar" sourcepath="ext/src/jedis-2.3.1.jar" />
<classpathentry kind="lib" path="ext/commons-pool2-2.0.jar" sourcepath="ext/src/commons-pool2-2.0.jar" />
<classpathentry kind="lib" path="ext/junit-4.11.jar" sourcepath="ext/src/junit-4.11.jar" />
<classpathentry kind="lib" path="ext/hamcrest-core-1.3.jar" sourcepath="ext/src/hamcrest-core-1.3.jar" />
<classpathentry kind="lib" path="ext/selenium-java-2.28.0.jar" sourcepath="ext/src/selenium-java-2.28.0.jar" />

+ 16
- 1
NOTICE View File

@@ -326,4 +326,19 @@ font-awesome
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

+ 1
- 0
build.moxie View File

@@ -170,6 +170,7 @@ dependencies:
- compile 'com.github.dblock.waffle:waffle-jna:1.5' :war
- compile 'org.kohsuke:libpam4j:1.7' :war
- compile 'commons-codec:commons-codec:1.7' :war
- compile 'redis.clients:jedis:2.3.1' :war
- test 'junit'
# Dependencies for Selenium web page testing
- test 'org.seleniumhq.selenium:selenium-java:${selenium.version}' @jar

+ 14
- 0
build.xml View File

@@ -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" />

+ 22
- 0
gitblit.iml View File

@@ -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>

+ 6
- 0
releases.moxie View File

@@ -59,6 +59,7 @@ r20: {
- Added an optional MirrorService which will periodically fetch ref updates from source repositories for mirrors (issue-5). Repositories must be manually cloned using native git and "--mirror".
- Added branch graph image servlet based on EGit's branch graph renderer (issue-194)
- Added option to render Markdown commit messages (issue-203)
- Added Ticket tracker and Patchset collaboration feature (issue-215)
- Added setting to control creating a repository as --shared on Unix servers (issue-263)
- Set Link: <url>; rel="canonical" http header for SEO (issue-304)
- Added raw links to the commit, commitdiff, and compare pages (issue-319)
@@ -86,6 +87,7 @@ r20: {
- added Dagger 1.1.0
- added Eclipse WikiText libraries for processing confluence, mediawiki, textile, tracwiki, and twiki
- added FontAwesome 4.0.3
- added Jedis 2.3.1
settings:
- { name: 'git.createRepositoriesShared', defaultValue: 'false' }
- { name: 'git.allowAnonymousPushes', defaultValue: 'false' }
@@ -105,6 +107,10 @@ r20: {
- { name: 'web.showBranchGraph', defaultValue: 'true' }
- { name: 'web.summaryShowReadme', defaultValue: 'false' }
- { name: 'server.redirectToHttpsPort', defaultValue: 'false' }
- { name: 'tickets.service', defaultValue: ' ' }
- { name: 'tickets.acceptNewTickets', defaultValue: 'true' }
- { name: 'tickets.acceptNewPatchsets', defaultValue: 'true' }
- { name: 'tickets.requireApproval', defaultValue: 'false' }
contributors:
- James Moger
- Robin Rosenberg

+ 12
- 0
src/main/distrib/data/clientapps.json View File

@@ -9,6 +9,17 @@
"icon": "git-black_32x32.png",
"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",
@@ -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+",

+ 70
- 0
src/main/distrib/data/gitblit.properties View File

@@ -429,6 +429,76 @@ git.streamFileThreshold = 50m
# 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
#

+ 15
- 0
src/main/distrib/linux/reindex-tickets.sh View 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


+ 13
- 0
src/main/distrib/win/reindex-tickets.cmd View 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%

+ 15
- 1
src/main/java/WEB-INF/web.xml View File

@@ -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>

+ 7
- 1
src/main/java/com/gitblit/Constants.java View File

@@ -108,12 +108,18 @@ public class Constants {
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) {

+ 154
- 0
src/main/java/com/gitblit/GitBlit.java View File

@@ -19,12 +19,14 @@ import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.List;

import javax.inject.Singleton;
import javax.servlet.http.HttpServletRequest;

import com.gitblit.Constants.AccessPermission;
import com.gitblit.manager.GitblitManager;
import com.gitblit.manager.IAuthenticationManager;
import com.gitblit.manager.IFederationManager;
import com.gitblit.manager.IGitblit;
import com.gitblit.manager.INotificationManager;
import com.gitblit.manager.IProjectManager;
import com.gitblit.manager.IRepositoryManager;
@@ -34,8 +36,17 @@ import com.gitblit.manager.ServicesManager;
import com.gitblit.models.RepositoryModel;
import com.gitblit.models.RepositoryUrl;
import com.gitblit.models.UserModel;
import com.gitblit.tickets.BranchTicketService;
import com.gitblit.tickets.FileTicketService;
import com.gitblit.tickets.ITicketService;
import com.gitblit.tickets.NullTicketService;
import com.gitblit.tickets.RedisTicketService;
import com.gitblit.utils.StringUtils;

import dagger.Module;
import dagger.ObjectGraph;
import dagger.Provides;

/**
* GitBlit is the aggregate manager for the Gitblit webapp. It provides all
* management functions and also manages some long-running services.
@@ -45,8 +56,12 @@ import com.gitblit.utils.StringUtils;
*/
public class GitBlit extends GitblitManager {

private final ObjectGraph injector;

private final ServicesManager servicesManager;

private ITicketService ticketService;

public GitBlit(
IRuntimeManager runtimeManager,
INotificationManager notificationManager,
@@ -64,6 +79,8 @@ public class GitBlit extends GitblitManager {
projectManager,
federationManager);

this.injector = ObjectGraph.create(getModules());

this.servicesManager = new ServicesManager(this);
}

@@ -72,6 +89,7 @@ public class GitBlit extends GitblitManager {
super.start();
logger.info("Starting services manager...");
servicesManager.start();
configureTicketService();
return this;
}

@@ -79,9 +97,14 @@ public class GitBlit extends GitblitManager {
public GitBlit stop() {
super.stop();
servicesManager.stop();
ticketService.stop();
return this;
}

protected Object [] getModules() {
return new Object [] { new GitBlitModule()};
}

/**
* Returns a list of repository URLs and the user access permission.
*
@@ -131,4 +154,135 @@ public class GitBlit extends GitblitManager {
}
return list;
}

/**
* Detect renames and reindex as appropriate.
*/
@Override
public void updateRepositoryModel(String repositoryName, RepositoryModel repository,
boolean isCreate) throws GitBlitException {
RepositoryModel oldModel = null;
boolean isRename = !isCreate && !repositoryName.equalsIgnoreCase(repository.name);
if (isRename) {
oldModel = repositoryManager.getRepositoryModel(repositoryName);
}

super.updateRepositoryModel(repositoryName, repository, isCreate);

if (isRename && ticketService != null) {
ticketService.rename(oldModel, repository);
}
}

/**
* Delete the repository and all associated tickets.
*/
@Override
public boolean deleteRepository(String repositoryName) {
RepositoryModel repository = repositoryManager.getRepositoryModel(repositoryName);
boolean success = repositoryManager.deleteRepository(repositoryName);
if (success && ticketService != null) {
return ticketService.deleteAll(repository);
}
return success;
}

/**
* Returns the configured ticket service.
*
* @return a ticket service
*/
@Override
public ITicketService getTicketService() {
return ticketService;
}

protected void configureTicketService() {
String clazz = settings.getString(Keys.tickets.service, NullTicketService.class.getName());
if (StringUtils.isEmpty(clazz)) {
clazz = NullTicketService.class.getName();
}
try {
Class<? extends ITicketService> serviceClass = (Class<? extends ITicketService>) Class.forName(clazz);
ticketService = injector.get(serviceClass).start();
if (ticketService.isReady()) {
logger.info("{} is ready.", ticketService);
} else {
logger.warn("{} is disabled.", ticketService);
}
} catch (Exception e) {
logger.error("failed to create ticket service " + clazz, e);
ticketService = injector.get(NullTicketService.class).start();
}
}

/**
* A nested Dagger graph is used for constructor dependency injection of
* complex classes.
*
* @author James Moger
*
*/
@Module(
library = true,
injects = {
IStoredSettings.class,

// core managers
IRuntimeManager.class,
INotificationManager.class,
IUserManager.class,
IAuthenticationManager.class,
IRepositoryManager.class,
IProjectManager.class,
IFederationManager.class,

// the monolithic manager
IGitblit.class,

// ticket services
NullTicketService.class,
FileTicketService.class,
BranchTicketService.class,
RedisTicketService.class
}
)
class GitBlitModule {

@Provides @Singleton IStoredSettings provideSettings() {
return settings;
}

@Provides @Singleton IRuntimeManager provideRuntimeManager() {
return runtimeManager;
}

@Provides @Singleton INotificationManager provideNotificationManager() {
return notificationManager;
}

@Provides @Singleton IUserManager provideUserManager() {
return userManager;
}

@Provides @Singleton IAuthenticationManager provideAuthenticationManager() {
return authenticationManager;
}

@Provides @Singleton IRepositoryManager provideRepositoryManager() {
return repositoryManager;
}

@Provides @Singleton IProjectManager provideProjectManager() {
return projectManager;
}

@Provides @Singleton IFederationManager provideFederationManager() {
return federationManager;
}

@Provides @Singleton IGitblit provideGitblit() {
return GitBlit.this;
}
}
}

+ 183
- 0
src/main/java/com/gitblit/ReindexTickets.java View 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;
}
}

+ 21
- 0
src/main/java/com/gitblit/client/EditRepositoryDialog.java View File

@@ -88,6 +88,12 @@ public class EditRepositoryDialog extends JDialog {
private JTextField descriptionField;
private JCheckBox acceptNewPatchsets;
private JCheckBox acceptNewTickets;
private JCheckBox requireApproval;

private JCheckBox useIncrementalPushTags;
private JCheckBox showRemoteBranches;
@@ -205,6 +211,12 @@ public class EditRepositoryDialog extends JDialog {
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 @@ public class EditRepositoryDialog extends JDialog {
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 @@ public class EditRepositoryDialog extends JDialog {
: 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();

+ 11
- 0
src/main/java/com/gitblit/git/GitblitReceivePack.java View File

@@ -50,6 +50,7 @@ import com.gitblit.client.Translation;
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 @@ public class GitblitReceivePack extends ReceivePack implements PreReceiveHook, P
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);
}
}

+ 9
- 2
src/main/java/com/gitblit/git/GitblitReceivePackFactory.java View File

@@ -100,10 +100,17 @@ public class GitblitReceivePackFactory<X> implements ReceivePackFactory<X> {
if (StringUtils.isEmpty(url)) {
url = gitblitUrl;
}
final RepositoryModel repository = gitblit.getRepositoryModel(repositoryName);

final GitblitReceivePack rp = new GitblitReceivePack(gitblit, db, repository, user);
// Determine which receive pack to use for pushes
final GitblitReceivePack rp;
if (gitblit.getTicketService().isAcceptingNewPatchsets(repository)) {
rp = new PatchsetReceivePack(gitblit, db, repository, user);
} else {
rp = new GitblitReceivePack(gitblit, db, repository, user);
}

rp.setGitblitUrl(url);
rp.setRefLogIdent(new PersonIdent(user.username, user.username + "@" + origin));
rp.setTimeout(timeout);

+ 324
- 0
src/main/java/com/gitblit/git/PatchsetCommand.java View 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);
}

}

+ 1129
- 0
src/main/java/com/gitblit/git/PatchsetReceivePack.java
File diff suppressed because it is too large
View File


+ 10
- 0
src/main/java/com/gitblit/manager/GitblitManager.java View File

@@ -62,6 +62,7 @@ import com.gitblit.models.ServerStatus;
import com.gitblit.models.SettingModel;
import com.gitblit.models.TeamModel;
import com.gitblit.models.UserModel;
import com.gitblit.tickets.ITicketService;
import com.gitblit.utils.ArrayUtils;
import com.gitblit.utils.HttpUtils;
import com.gitblit.utils.JGitUtils;
@@ -483,6 +484,15 @@ public class GitblitManager implements IGitblit {
}
}

/**
* Throws an exception if trying to get a ticket service.
*
*/
@Override
public ITicketService getTicketService() {
throw new RuntimeException("This class does not have a ticket service!");
}

/*
* ISTOREDSETTINGS
*

+ 8
- 0
src/main/java/com/gitblit/manager/IGitblit.java View File

@@ -26,6 +26,7 @@ import com.gitblit.models.RepositoryModel;
import com.gitblit.models.RepositoryUrl;
import com.gitblit.models.TeamModel;
import com.gitblit.models.UserModel;
import com.gitblit.tickets.ITicketService;

public interface IGitblit extends IManager,
IRuntimeManager,
@@ -101,4 +102,11 @@ public interface IGitblit extends IManager,
*/
Collection<GitClientApplication> getClientApplications();

/**
* Returns the ticket service.
*
* @return a ticket service
*/
ITicketService getTicketService();

}

+ 12
- 0
src/main/java/com/gitblit/manager/RepositoryManager.java View File

@@ -801,6 +801,9 @@ public class RepositoryManager implements IRepositoryManager {
model.description = getConfig(config, "description", "");
model.originRepository = getConfig(config, "originRepository", null);
model.addOwners(ArrayUtils.fromString(getConfig(config, "owner", "")));
model.acceptNewPatchsets = getConfig(config, "acceptNewPatchsets", true);
model.acceptNewTickets = getConfig(config, "acceptNewTickets", true);
model.requireApproval = getConfig(config, "requireApproval", settings.getBoolean(Keys.tickets.requireApproval, false));
model.useIncrementalPushTags = getConfig(config, "useIncrementalPushTags", false);
model.incrementalPushTagPrefix = getConfig(config, "incrementalPushTagPrefix", null);
model.allowForks = getConfig(config, "allowForks", true);
@@ -1406,6 +1409,15 @@ public class RepositoryManager implements IRepositoryManager {
config.setString(Constants.CONFIG_GITBLIT, null, "description", repository.description);
config.setString(Constants.CONFIG_GITBLIT, null, "originRepository", repository.originRepository);
config.setString(Constants.CONFIG_GITBLIT, null, "owner", ArrayUtils.toString(repository.owners));
config.setBoolean(Constants.CONFIG_GITBLIT, null, "acceptNewPatchsets", repository.acceptNewPatchsets);
config.setBoolean(Constants.CONFIG_GITBLIT, null, "acceptNewTickets", repository.acceptNewTickets);
if (settings.getBoolean(Keys.tickets.requireApproval, false) == repository.requireApproval) {
// use default
config.unset(Constants.CONFIG_GITBLIT, null, "requireApproval");
} else {
// override default
config.setBoolean(Constants.CONFIG_GITBLIT, null, "requireApproval", repository.requireApproval);
}
config.setBoolean(Constants.CONFIG_GITBLIT, null, "useIncrementalPushTags", repository.useIncrementalPushTags);
if (StringUtils.isEmpty(repository.incrementalPushTagPrefix) ||
repository.incrementalPushTagPrefix.equals(settings.getString(Keys.git.defaultIncrementalPushTagPrefix, "r"))) {

+ 11
- 0
src/main/java/com/gitblit/models/RepositoryModel.java View File

@@ -85,6 +85,9 @@ public class RepositoryModel implements Serializable, Comparable<RepositoryModel
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 @@ public class RepositoryModel implements Serializable, Comparable<RepositoryModel
this.projectPath = StringUtils.getFirstPathElement(name);
this.owners = new ArrayList<String>();
this.isBare = true;
this.acceptNewTickets = true;
this.acceptNewPatchsets = true;
addOwner(owner);
}
@@ -140,6 +145,10 @@ public class RepositoryModel implements Serializable, Comparable<RepositoryModel
displayName = null;
}
public String getRID() {
return StringUtils.getSHA1(name);
}
@Override
public int hashCode() {
return name.hashCode();
@@ -209,6 +218,8 @@ public class RepositoryModel implements Serializable, Comparable<RepositoryModel
clone.federationStrategy = federationStrategy;
clone.showRemoteBranches = false;
clone.allowForks = false;
clone.acceptNewPatchsets = false;
clone.acceptNewTickets = false;
clone.skipSizeCalculation = skipSizeCalculation;
clone.skipSummaryMetrics = skipSummaryMetrics;
clone.sparkleshareId = sparkleshareId;

+ 1286
- 0
src/main/java/com/gitblit/models/TicketModel.java
File diff suppressed because it is too large
View File


+ 12
- 0
src/main/java/com/gitblit/models/UserModel.java View File

@@ -446,6 +446,18 @@ public class UserModel implements Principal, Serializable, Comparable<UserModel>
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.

+ 201
- 0
src/main/java/com/gitblit/servlet/PtServlet.java View 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];
}
}

+ 799
- 0
src/main/java/com/gitblit/tickets/BranchTicketService.java View 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();
}
}

+ 467
- 0
src/main/java/com/gitblit/tickets/FileTicketService.java View 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();
}
}

+ 1088
- 0
src/main/java/com/gitblit/tickets/ITicketService.java
File diff suppressed because it is too large
View File


+ 129
- 0
src/main/java/com/gitblit/tickets/NullTicketService.java View 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();
}
}

+ 222
- 0
src/main/java/com/gitblit/tickets/QueryBuilder.java View 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();
}
}

+ 114
- 0
src/main/java/com/gitblit/tickets/QueryResult.java View 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;
}
}

+ 534
- 0
src/main/java/com/gitblit/tickets/RedisTicketService.java View 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) + ")";
}
}

+ 657
- 0
src/main/java/com/gitblit/tickets/TicketIndexer.java View 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;
}
}

+ 77
- 0
src/main/java/com/gitblit/tickets/TicketLabel.java View 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;
}
}

+ 53
- 0
src/main/java/com/gitblit/tickets/TicketMilestone.java View 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;
}
}

+ 617
- 0
src/main/java/com/gitblit/tickets/TicketNotifier.java View 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();
}
}

+ 59
- 0
src/main/java/com/gitblit/tickets/TicketResponsible.java View 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());
}
}

+ 175
- 0
src/main/java/com/gitblit/tickets/TicketSerializer.java View 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);
}
}
}
}

+ 11
- 0
src/main/java/com/gitblit/tickets/commands.md View 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



+ 38
- 0
src/main/java/com/gitblit/tickets/email.css View 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;
}

+ 207
- 0
src/main/java/com/gitblit/utils/JGitUtils.java View File

@@ -59,6 +59,8 @@ import org.eclipse.jgit.lib.Repository;
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.eclipse.jgit.util.FS;
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 @@ public class JGitUtils {
}
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);
}
}

+ 2
- 2
src/main/java/com/gitblit/utils/JsonUtils.java View File

@@ -274,10 +274,10 @@ public class JsonUtils {
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"));
}

+ 4
- 0
src/main/java/com/gitblit/utils/MarkdownUtils.java View File

@@ -132,6 +132,10 @@ public class MarkdownUtils {
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);

+ 34
- 6
src/main/java/com/gitblit/utils/RefLogUtils.java View File

@@ -213,6 +213,22 @@ public class RefLogUtils {
*/
public static boolean updateRefLog(UserModel user, Repository repository,
Collection<ReceiveCommand> commands) {

// only track branches and tags
List<ReceiveCommand> filteredCommands = new ArrayList<ReceiveCommand>();
for (ReceiveCommand cmd : commands) {
if (!cmd.getRefName().startsWith(Constants.R_HEADS)
&& !cmd.getRefName().startsWith(Constants.R_TAGS)) {
continue;
}
filteredCommands.add(cmd);
}

if (filteredCommands.isEmpty()) {
// nothing to log
return true;
}

RefModel reflogBranch = getRefLogBranch(repository);
if (reflogBranch == null) {
JGitUtils.createOrphanBranch(repository, GB_REFLOG, null);
@@ -443,7 +459,15 @@ public class RefLogUtils {
Date date = push.getAuthorIdent().getWhen();

RefLogEntry log = new RefLogEntry(repositoryName, date, user);
List<PathChangeModel> changedRefs = JGitUtils.getFilesInCommit(repository, push);

// only report HEADS and TAGS for now
List<PathChangeModel> changedRefs = new ArrayList<PathChangeModel>();
for (PathChangeModel refChange : JGitUtils.getFilesInCommit(repository, push)) {
if (refChange.path.startsWith(Constants.R_HEADS)
|| refChange.path.startsWith(Constants.R_TAGS)) {
changedRefs.add(refChange);
}
}
if (changedRefs.isEmpty()) {
// skip empty commits
continue;
@@ -466,12 +490,16 @@ public class RefLogUtils {
// ref deletion
continue;
}
List<RevCommit> pushedCommits = JGitUtils.getRevLog(repository, oldId, newId);
for (RevCommit pushedCommit : pushedCommits) {
RepositoryCommit repoCommit = log.addCommit(change.path, pushedCommit);
if (repoCommit != null) {
repoCommit.setRefs(allRefs.get(pushedCommit.getId()));
try {
List<RevCommit> pushedCommits = JGitUtils.getRevLog(repository, oldId, newId);
for (RevCommit pushedCommit : pushedCommits) {
RepositoryCommit repoCommit = log.addCommit(change.path, pushedCommit);
if (repoCommit != null) {
repoCommit.setRefs(allRefs.get(pushedCommit.getId()));
}
}
} catch (Exception e) {

}
}
}

+ 15
- 0
src/main/java/com/gitblit/wicket/GitBlitWebApp.java View File

@@ -38,6 +38,7 @@ import com.gitblit.manager.IProjectManager;
import com.gitblit.manager.IRepositoryManager;
import com.gitblit.manager.IRuntimeManager;
import com.gitblit.manager.IUserManager;
import com.gitblit.tickets.ITicketService;
import com.gitblit.utils.StringUtils;
import com.gitblit.wicket.pages.ActivityPage;
import com.gitblit.wicket.pages.BlamePage;
@@ -49,6 +50,8 @@ import com.gitblit.wicket.pages.CommitPage;
import com.gitblit.wicket.pages.ComparePage;
import com.gitblit.wicket.pages.DocPage;
import com.gitblit.wicket.pages.DocsPage;
import com.gitblit.wicket.pages.EditTicketPage;
import com.gitblit.wicket.pages.ExportTicketPage;
import com.gitblit.wicket.pages.FederationRegistrationPage;
import com.gitblit.wicket.pages.ForkPage;
import com.gitblit.wicket.pages.ForksPage;
@@ -59,6 +62,7 @@ import com.gitblit.wicket.pages.LogoutPage;
import com.gitblit.wicket.pages.LuceneSearchPage;
import com.gitblit.wicket.pages.MetricsPage;
import com.gitblit.wicket.pages.MyDashboardPage;
import com.gitblit.wicket.pages.NewTicketPage;
import com.gitblit.wicket.pages.OverviewPage;
import com.gitblit.wicket.pages.PatchPage;
import com.gitblit.wicket.pages.ProjectPage;
@@ -70,6 +74,7 @@ import com.gitblit.wicket.pages.ReviewProposalPage;
import com.gitblit.wicket.pages.SummaryPage;
import com.gitblit.wicket.pages.TagPage;
import com.gitblit.wicket.pages.TagsPage;
import com.gitblit.wicket.pages.TicketsPage;
import com.gitblit.wicket.pages.TreePage;
import com.gitblit.wicket.pages.UserPage;
import com.gitblit.wicket.pages.UsersPage;
@@ -168,6 +173,12 @@ public class GitBlitWebApp extends WebApplication {
mount("/users", UsersPage.class);
mount("/logout", LogoutPage.class);

// setup ticket urls
mount("/tickets", TicketsPage.class, "r", "h");
mount("/tickets/new", NewTicketPage.class, "r");
mount("/tickets/edit", EditTicketPage.class, "r", "h");
mount("/tickets/export", ExportTicketPage.class, "r", "h");

// setup the markup document urls
mount("/docs", DocsPage.class, "r");
mount("/doc", DocPage.class, "r", "h", "f");
@@ -285,6 +296,10 @@ public class GitBlitWebApp extends WebApplication {
return gitblit;
}

public ITicketService tickets() {
return gitblit.getTicketService();
}

public TimeZone getTimezone() {
return runtimeManager.getTimezone();
}

+ 139
- 2
src/main/java/com/gitblit/wicket/GitBlitWebApp.properties View File

@@ -18,7 +18,7 @@ gb.object = object
gb.ticketId = ticket id
gb.ticketAssigned = assigned
gb.ticketOpenDate = open date
gb.ticketState = state
gb.ticketStatus = status
gb.ticketComments = comments
gb.view = view
gb.local = local
@@ -511,4 +511,141 @@ gb.mirrorOf = mirror of {0}
gb.mirrorWarning = this repository is a mirror and can not receive pushes
gb.docsWelcome1 = You can use docs to document your repository.
gb.docsWelcome2 = Commit a README.md or a HOME.md file to get started.
gb.createReadme = create a README
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}

+ 25
- 0
src/main/java/com/gitblit/wicket/pages/BasePage.java View File

@@ -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 java.util.regex.Pattern;
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 @@ public abstract class BasePage extends SessionPage {
}
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();
}
}

+ 17
- 14
src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.html View File

@@ -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>

+ 7
- 4
src/main/java/com/gitblit/wicket/pages/EditRepositoryPage.java View File

@@ -410,12 +410,12 @@ public class EditRepositoryPage extends RootSubPage {
}
// 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 @@ public class EditRepositoryPage extends RootSubPage {
}
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) ? ""

+ 66
- 0
src/main/java/com/gitblit/wicket/pages/EditTicketPage.html View 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>

+ 290
- 0
src/main/java/com/gitblit/wicket/pages/EditTicketPage.java View 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;
}
}

+ 82
- 0
src/main/java/com/gitblit/wicket/pages/ExportTicketPage.java View 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);
}
}
}

+ 66
- 0
src/main/java/com/gitblit/wicket/pages/NewTicketPage.html View 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>

+ 202
- 0
src/main/java/com/gitblit/wicket/pages/NewTicketPage.java View 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;
}
}

+ 21
- 0
src/main/java/com/gitblit/wicket/pages/NoTicketsPage.html View 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>

+ 44
- 0
src/main/java/com/gitblit/wicket/pages/NoTicketsPage.java View 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;
}
}

+ 1
- 0
src/main/java/com/gitblit/wicket/pages/RepositoryPage.html View File

@@ -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>

+ 18
- 2
src/main/java/com/gitblit/wicket/pages/RepositoryPage.java View File

@@ -56,6 +56,7 @@ import com.gitblit.models.UserModel;
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 abstract class RepositoryPage extends RootPage {
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 @@ public abstract class RepositoryPage extends RootPage {
}
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 @@ public abstract class RepositoryPage extends RootPage {
}
}
// 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());

+ 124
- 0
src/main/java/com/gitblit/wicket/pages/TicketBasePage.java View 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;
}
}

+ 577
- 0
src/main/java/com/gitblit/wicket/pages/TicketPage.html View 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>

+ 1527
- 0
src/main/java/com/gitblit/wicket/pages/TicketPage.java
File diff suppressed because it is too large
View File


+ 215
- 0
src/main/java/com/gitblit/wicket/pages/TicketsPage.html View 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>

+ 878
- 0
src/main/java/com/gitblit/wicket/pages/TicketsPage.java View 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));
}
}
}

+ 6
- 0
src/main/java/com/gitblit/wicket/pages/propose_git.md View 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}

+ 5
- 0
src/main/java/com/gitblit/wicket/pages/propose_pt.md View File

@@ -0,0 +1,5 @@
git clone ${url}
cd ${repo}
pt start ${ticketId}
...
pt propose

+ 29
- 0
src/main/java/com/gitblit/wicket/panels/CommentPanel.html View 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>

+ 110
- 0
src/main/java/com/gitblit/wicket/panels/CommentPanel.java View 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);
}
}
}

+ 276
- 263
src/main/java/com/gitblit/wicket/panels/DigestsPanel.java View File

@@ -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;
}
}

+ 73
- 69
src/main/java/com/gitblit/wicket/panels/GravatarImage.java View File

@@ -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);
}
}

+ 118
- 0
src/main/java/com/gitblit/wicket/panels/MarkdownTextArea.java View 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);
}
}
}

+ 2
- 2
src/main/java/com/gitblit/wicket/panels/ReflogPanel.html View File

@@ -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>

+ 325
- 313
src/main/java/com/gitblit/wicket/panels/ReflogPanel.java View File

@@ -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;
}
}

+ 23
- 4
src/main/java/com/gitblit/wicket/panels/RefsPanel.java View File

@@ -25,7 +25,6 @@ import java.util.Map;
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 org.eclipse.jgit.revwalk.RevCommit;
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 @@ public class RefsPanel extends Panel {
}
}
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 @@ public class RefsPanel extends Panel {
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 @@ public class RefsPanel extends Panel {
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());

+ 1
- 0
src/main/java/pt.cmd View File

@@ -0,0 +1 @@
@python %~dp0pt.py %1 %2 %3 %4 %5 %6 %7 %8 %9

+ 701
- 0
src/main/java/pt.py View 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)

+ 49
- 0
src/main/java/pt.txt View 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>]


BIN
src/main/resources/barnum_32x32.png View File


+ 409
- 5
src/main/resources/gitblit.css View File

@@ -38,11 +38,53 @@ a.bugtraq {
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 @@ navbar div>ul .menu-dropdown li a:hover,.nav .menu-dropdown li a:hover,.navbar d
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 @@ th {
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 @@ div.featureWelcome div.icon {
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 @@ pre.prettyprint ol {
border: 1px solid #ccc;
color: #ccc;
font-weight:bold;
display: inline-block;
}
.diffstat-inline {
@@ -650,7 +736,207 @@ pre.prettyprint ol {
.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 @@ span.empty {
color: #008000;
}
span.highlight {
background-color: rgb(255, 255, 100);
color: black;
padding: 0px 2px;
}

span.link {
color: #888;
}
@@ -775,11 +1067,17 @@ img.overview {
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 @@ div.references {
text-align: right;
}
table.plain, table.summary {
table.plain, table.summary, table.ticket {
width: 0 !important;
border: 0;
}
@@ -1168,11 +1466,16 @@ table.plain th, table.plain td, table.summary th, table.summary td {
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 @@ div.markdown table.text th, div.markdown table.text td {
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;
}

+ 1
- 0
src/site/design.mkd View File

@@ -53,6 +53,7 @@ The following dependencies are automatically downloaded by Gitblit GO (or alread
- [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)

+ 79
- 0
src/site/tickets_barnum.mkd View 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.

+ 145
- 0
src/site/tickets_overview.mkd View 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.

+ 119
- 0
src/site/tickets_setup.mkd View 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.

+ 155
- 0
src/site/tickets_using.mkd View 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.

+ 68
- 0
src/test/java/com/gitblit/tests/BranchTicketServiceTest.java View 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;
}
}

+ 67
- 0
src/test/java/com/gitblit/tests/FileTicketServiceTest.java View 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;
}
}

+ 7
- 1
src/test/java/com/gitblit/tests/GitBlitSuite.java View File

@@ -63,7 +63,8 @@ import com.gitblit.utils.JGitUtils;
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 @@ public class GitBlitSuite {
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);

+ 75
- 0
src/test/java/com/gitblit/tests/RedisTicketServiceTest.java View 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;
}
}

+ 351
- 0
src/test/java/com/gitblit/tests/TicketServiceTest.java View 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);
}
}

Loading…
Cancel
Save