New jlp-based documentation for pit-cli.

This commit is contained in:
Jonathan Bernard 2012-02-13 12:12:26 -06:00
parent c0b02ca222
commit d0e968b2b7

View File

@ -1,3 +1,10 @@
/**
* # Personal Issue Tracker Command Line Interface
* @author Jonathan Bernard <jdbernard@gmail.com>
* @copyright 2009-2012 Jonathan Bernard
*
* This is a command-line interface to my personal issue tracker system.
*/
package com.jdbernard.pit
import com.jdbernard.pit.file.*
@ -11,47 +18,127 @@ import static java.lang.Math.min
def log = LoggerFactory.getLogger(getClass())
// -------- command-line interface specification -------- //
/// ## Command Line Options ##
/// --------------------------
def cli = new CliBuilder(usage: 'pit-cli [options]')
/// -h,--help
/// : Show help information
cli.h(longOpt: 'help', 'Show help information.')
/// -v,--verbose
/// : Show verbose task information.
cli.v(longOpt: 'verbose', 'Show verbose task information')
/// -l,--list
/// : List issues in the current project.
cli.l(longOpt: 'list', 'List issues in the current project.')
/// -i,--id
/// : Filter issues by id. Accepts a comma-delimited list.
/// *Example:* `pit -l -i 0001,0002`
cli.i(argName: 'id', longOpt: 'id', args: 1,
'Filter issues by id. Accepts a comma-delimited list.')
/// -c,--category
/// : Filter issues by category (bug, feature, task). Accepts a
/// comma-delimited list. By default all categories are selected. The full
/// category name is not required, just enough to be uniquely identifiable.
/// *Example:* `pit -l -c bug,t # List bugs and tasks.`
cli.c(argName: 'category', longOpt: 'category', args: 1,
'Filter issues by category (bug, feature, task). Accepts a '
+ 'comma-delimited list. By default all categories are selected.')
/// -s,--status
/// : Filter issues by status (new, reassigned, rejected, resolved,
/// validation_required). The full status is not required, just enough to
/// uniquely identify the status.
/// *Example:* `pit -l -s reas,rej # List Reassigned and Rejected issues.`
cli.s(argName: 'status', longOpt: 'status', args: 1,
'Filter issues by status (new, reassigned, rejected, resolved, ' +
'validation_required)')
/// -p,--priority
/// : Filter issues by priority. This acts as a threshhold, listing all
/// issues greater than or equal to the given priority.
/// *Example:* `pit -l -p 5 # List all issues with priority >= 5`
cli.p(argName: 'priority', longOpt: 'priority', args: 1,
'Filter issues by priority. This acts as a threshhold, listing all issues '
+ 'greater than or equal to the given priority.')
/// -r,--project
/// : Filter issues by project (relative to the current directory). Accepts a
/// comma-delimited list. This option should be used in conjunction with the
/// `R,--recursive` option.
/// *Example:* `pit -l -R --project <project_name>`
cli.r(argName: 'project', longOpt: 'project', args: 1,
'Filter issues by project (relative to the current directory). Accepts a '
+ 'comma-delimited list.')
/// -R,--recursive
/// : Recursively include subprojects.
cli.R(longOpt: 'recursive', 'Include subprojects.')
/// -e,--extended-property
/// : Filter for issues by extended property. Format is
/// `-e <propname>=<propvalue>`.
cli.e(argName: 'extended-property', args: 1, 'Filter for issues by extended ' +
'property. Format is "-e <propname>=<propvalue>".')
/*cli.s(longOpt: 'show-subprojects',
'Include sup projects in listing (default behaviour)')
cli.S(longOpt: 'no-subprojects', 'Do not list subprojects.')*/ // TODO: figure out better flags for these options.
/// -P,--set-priority
/// : Modify the priority of the selected issues. Requires a value from 0-9.
cli.P(argName: 'new-priority', longOpt: 'set-priority', args: 1,
'Modify the priority of the selected issues.')
/// -C,--set-category
/// : Modify the category of the selected issues.
cli.C(argName: 'new-category', longOpt: 'set-category', args: 1,
'Modify the category of the selected issues.')
/// -S,--set-status
/// : Modify the status of the selected issues.
cli.S(argName: 'new-status', longOpt: 'set-status', args: 1,
'Modify the status of the selected issues.')
cli.E(argName: 'new-extended-property', args: 1, 'Modify the extended ' +
'property of the selected issues. Format is "-E <propname>=<propvalue>"')
/// -E,--new-issue
/// : Modify the extended property of the selected issues. Format is
/// `-E <propname>=<propvalue>`
cli.E(argName: 'new-extended-property', longOpt: 'set-extended-property',
args: 1, 'Modify the extended property of the selected issues. Format ' +
'is "-E <propname>=<propvalue>"')
/// -n,--new-issue
/// : Create a new issue
cli.n(longOpt: 'new-issue', 'Create a new issue.')
/// --title
/// : Give the title for a new issue or modify the title for an existing
/// issue. By default the title for a new issue is expected on stanard
/// input.
cli._(longOpt: 'title', args: 1, argName: 'title', 'Give the title for a new' +
' issue or modify the title for an existing issue. By default the title' +
' for a new issue is expected on stanard input.')
/// --text
/// : Give the text for a new issue or modify the text for an exising
/// issue. By default the text for a new issue is expected on standard
/// input.
cli._(longOpt: 'text', args: 1, argName: 'text', 'Give the text for a new' +
' issue or modify the text for an exising issue. By default the text for' +
' a new issue is expected on standard input.')
/** -o,--order
* : Order (sort) the results by the given properties. Provide a
* comma-seperated list of property names to sort by in order of
* importance. The basic properties (id, category, status, and priority)
* can be given using their one-letter forms (i,c,s,p) for brevity. For
* example: `-o Due,p,c` would sort first by the extended property `Due`,
* then for items that have the same `Due` value it would sort by
* priority, then by category. */
cli.o(longOpt: 'order', argName: 'order', args: 1, required: false,
'Order (sort) the results by the given properties. Provide a comma-' +
'seperated list of property names to sort by in order of importance. The' +
@ -60,31 +147,64 @@ cli.o(longOpt: 'order', argName: 'order', args: 1, required: false,
' "-o Due,p,c" would sort first by the extended property "Due", then for' +
' items that have the same "Due" value it would sort by priority, then' +
' by category.')
/// -d,--dir
/// : Use `<dir>` as the base directory (defaults to current directory).
cli.d(longOpt: 'dir', argName: 'dir', args: 1, required: false,
'Use <dir> as the base directory (defaults to current directory).')
/// -D,--daily-list
/// : Print a Daily Task list based on issue Due, Scheduled, and Reminder
/// extended properties.
cli.D(longOpt: 'daily-list', 'Print a Daily Task list based on issue Due and' +
' Reminder properties.')
/// --dl-scheduled
/// : Show scheduled tasks in the daily list (all are shown by default).
cli._(longOpt: 'dl-scheduled', 'Show scheduled tasks in the daily list (all' +
' are shown by default).')
/// --dl-due
/// : Show due tasks in the daily list (all are shown by default).
cli._(longOpt: 'dl-due', 'Show due tasks in the daily list (all are shown by' +
' default).')
/// --dl-reminder
/// : Show upcoming tasks in the daily list (all are shown by default).
cli._(longOpt: 'dl-reminder', 'Show upcoming tasks in the daily list (all ' +
' are shown by default).')
/// --dl-open
/// : Show open tasks in the daily list (all are shown by default).
cli._(longOpt: 'dl-open', 'Show open tasks in the daily list (all are shown ' +
' by default).')
/// --dl-hide-scheduled
/// : Hide scheduled tasks in the daily list (all are shown by default).
cli._(longOpt: 'dl-hide-scheduled', 'Hide scheduled tasks in the daily list' +
' (all are shown by default).')
/// --dl-hide-due
/// : Show due tasks in the daily list (all are shown by default).
cli._(longOpt: 'dl-hide-due', 'Show due tasks in the daily list (all are' +
' shown by default).')
/// --dl-hide-reminder
/// : Show upcoming tasks in the daily list (all are shown by default).
cli._(longOpt: 'dl-hide-reminder', 'Show upcoming tasks in the daily list' +
' (all are shown by default).')
/// --dl-hide-open
/// : Show open tasks in the daily list (all are shown by default).
cli._(longOpt: 'dl-hide-open', 'Show open tasks in the daily list (all are' +
' shown by default).')
/// --version
/// : Display PIT version information.
cli._(longOpt: 'version', 'Display PIT version information.')
// =================================== //
// ======== Parse CLI Options ======== //
// =================================== //
/// ## Parse CLI Options ##
/// -----------------------
log.trace("Parsing options.")
@ -93,7 +213,7 @@ def opts = cli.parse(args)
def issuedb = [:]
def workingDir = new File('.')
// defaults for the issue filter/selector
/// Defaults for the issue filter/selector.
def selectOpts = [
categories: ['bug', 'feature', 'task'],
status: ['new', 'reassigned', 'rejected',
@ -104,19 +224,19 @@ def selectOpts = [
extendedProperties: [:],
acceptProjects: true]
// options for changing properties of issue(s)
/// Defaults for changing properties of issue(s)
def assignOpts = [:]
if (!opts || opts.h) {
cli.usage()
System.exit(0) }
// read the category filter designation(s)
///Read the `-c` option: category filter designation(s).
if (opts.c) {
if (opts.c =~ /all/) {} // no-op, same as defaults
else { selectOpts.categories = opts.c.split(/[,\s]/) } }
// parse the categories names into Category objects
/// Parse the categories names into Category objects.
try { selectOpts.categories =
selectOpts.categories.collect { Category.toCategory(it) } }
catch (Exception e) {
@ -125,15 +245,15 @@ catch (Exception e) {
println " (abbreviations are accepted)."
System.exit(1) }
// read the status filter designation(s)
/// Read the `-s` option: status filter designation(s).
if (opts.s) {
// -s all
if (opts.s =~ /all/) selectOpts.status = ['new', 'reassigned', 'rejected',
'resolved', 'validation_required']
// is <list>
// <list>
else selectOpts.status = opts.s.split(/[,\s]/) }
// parse the statuses into Status objects
/// Parse the statuses into Status objects.
try { selectOpts.status =
selectOpts.status.collect { Status.toStatus(it) } }
catch (Exception e) {
@ -142,7 +262,7 @@ catch (Exception e) {
println " (abbreviations are accepted.)"
System.exit(1) }
// read and parse the priority filter
/// Read and parse the `-p` option: priority filter.
if (opts.p) try {
selectOpts.priority = opts.p.toInteger() }
catch (NumberFormatException nfe) {
@ -150,14 +270,14 @@ catch (NumberFormatException nfe) {
println "Valid values are: 0-9"
System.exit(1) }
// read and parse the projects filter
/// Read and parse the `-r` option: projects filter.
if (opts.r) { selectOpts.projects =
opts.r.toLowerCase().split(/[,\s]/).asType(List.class) }
// read and parse the ids filter
/// Read and parse the `-i` option: id filter.
if (opts.i) { selectOpts.ids = opts.i.split(/[,\s]/).asType(List.class) }
// read and parse sort criteria
/// Read and parse the `-o` option: sort criteria.
if (opts.o) {
def sortProps = opts.o.split(',')
selectOpts.issueSorter = sortProps.collect { prop ->
@ -168,16 +288,14 @@ if (opts.o) {
case ~/^c$/: return { issue -> issue.category }
default: return { issue -> issue[prop] } }}}
// read and parse extended property selection criteria
/// Read and parse any extended property selection criteria.
if (opts.e) {
opts.es.each { option ->
def parts = option.split("=")
selectOpts.extendedProperties[parts[0]] =
ExtendedPropertyHelp.parse(parts[1]) }}
// TODO: accept projects value from input
// read and parse the category to assign
/// Read and parse the `-C` option: category to assign.
if (opts.C) try { assignOpts.category = Category.toCategory(opts.C) }
catch (Exception e) {
println "Invalid category option: '-C ${e.localizedMessage}'."
@ -185,7 +303,7 @@ catch (Exception e) {
println " (abbreviations are accepted)."
System.exit(1) }
// read and parse the status to assign
/// Read and parse the `-S` option: status to assign.
if (opts.S) try { assignOpts.status = Status.toStatus(opts.S) }
catch (Exception e) {
println "Invalid status option: '-S ${e.localizedMessage}'."
@ -193,25 +311,26 @@ catch (Exception e) {
println " (abbreviations are accepted)."
System.exit(1) }
// read and parse the priority to assign
/// Read and parse the `-P` option: priority to assign.
if (opts.P) try {assignOpts.priority = opts.P.toInteger() }
catch (NumberFormatException nfe) {
println "Not a valid priority value: '-P ${opts.P}'."
println "Valid values are: 0-9"
System.exit(1) }
/// Read an parse any extended properties to be set.
if (opts.E) {
opts.Es.each { option ->
def parts = option.split("=")
assignOpts[parts[0]] = ExtendedPropertyHelp.parse(parts[1]) }}
// Read the title if given.
/// Read the title if given.
if (opts.title) { assignOpts.title = opts.title }
// Read the text if given
/// Read the text if given.
if (opts.text) { assignOpts.text = opts.text }
// set the project working directory
/// Set the project working directory.
if (opts.d) {
workingDir = new File(opts.d.trim())
if (!workingDir.exists()) {
@ -223,32 +342,33 @@ def EOL = System.getProperty('line.separator')
log.debug("Finished parsing options:\nworkingDir: {}\nselectOpts: {}\nassignOpts: {}",
workingDir.canonicalPath, selectOpts, assignOpts)
// ========================= //
// ======== Actions ======== //
// ========================= //
/// ## Actions ##
/// -------------
// list version information first
/// ### Version information.
if (opts.version) {
println "PIT CLI Version ${VERSION}"
println "Written by Jonathan Bernard\n" }
/// ----
else {
// build issue list
/// Build issue list.
log.trace("Building issue database.")
issuedb = new FileProject(workingDir)
// build filter from options
/// Build filter from options.
log.trace("Defining the filter.")
def filter = new Filter(selectOpts)
// list second
/// ### List
if (opts.l) {
log.trace("Listing issues.")
// local function (closure) to print a single issue
/// Local function (closure) to print a single issue.
def printIssue = { issue, offset ->
println "${offset}${issue}"
if (opts.v) {
@ -260,7 +380,7 @@ if (opts.l) {
println "${offset} * ${name}: ${formattedValue}"}
println ""}}
// local function (closure) to print a project and all visible subprojects
/// Local function (closure) to print a project and all visible subprojects.
def printProject
printProject = { project, offset ->
println "\n${offset}${project.name}"
@ -268,65 +388,70 @@ if (opts.l) {
project.eachIssue(filter) { printIssue(it, offset) }
project.eachProject(filter) { printProject(it, offset + " ") } }
// print all the issues in the root of this db
/// Print all the issues in the root of this db.
issuedb.eachIssue(filter) { printIssue(it, "") }
/// If the user set the recursive flag print all projects.
if (opts.R) {
// print all projects
issuedb.eachProject(filter) { printProject(it, "") }} }
// daily list second
/// ### Daily List
else if (opts.D) {
log.trace("Showing a daily list.")
// Parse daily list specific display options
/// #### Parse daily list specific display options.
def visibleSections = []
def suppressedSections
// Parse the additive options first.
/// Parse the additive options first.
if (opts.'dl-scheduled') { visibleSections << 'scheduled' }
if (opts.'dl-due') { visibleSections << 'due' }
if (opts.'dl-reminder') { visibleSections << 'reminder' }
if (opts.'dl-open') { visibleSections << 'open' }
// If the user did not add any sections assume they want them all.
/// If the user did not add any sections assume they want them all.
if (visibleSections.size() == 0) {
visibleSections = ['scheduled', 'due', 'reminder', 'open'] }
// Now go through the negative options.
/// Now go through the negative options.
if (opts.'dl-hide-scheduled') { visibleSections -= 'scheduled' }
if (opts.'dl-hide-due') { visibleSections -= 'due' }
if (opts.'dl-hide-reminder') { visibleSections -= 'reminder' }
if (opts.'dl-hide-open') { visibleSections -= 'open' }
// If the user did not specifically ask for a status filter, we want a
// different filter for the default when we are doing a daily list.
/// If the user did not specifically ask for a status filter, we want a
/// different default filter when we are doing a daily list.
if (!opts.s) { filter.status = [Status.NEW, Status.VALIDATION_REQUIRED] }
// If the user did not give a specific sorting order, define our own.
/// If the user did not give a specific sorting order, define our own: due
/// date, then priority, then id.
if (!opts.o) { filter.issueSorter = [ {it.due}, {it.priority}, {it.id} ] }
// Get our issues
/// #### Get all the issues involved.
def allIssues = opts.R ?
// If -R passed, get all issues, including subprojects.
/// If `-R` passed, get all issues, including subprojects.
issuedb.getAllIssues(filter) :
// Otherwise, just use the issues for this project.
/// Otherwise, just use the issues for this project.
issuedb.issues.values().findAll { filter ? filter.accept(it) : true }
// Set up our time interval.
/// Set up our time intervals.
def today = new DateMidnight()
def tomorrow = today.plusDays(1)
/// We are going to sort the issues into these buckets based on when they are
/// scheduled, when they are duem and if they have a reminder set.
def scheduledToday = []
def dueToday = []
def reminderToday = []
def notDueOrReminder = []
/// Helper closure to print an issue.
def printIssue = { issue ->
if (issue.due) println "${issue.due.toString('EEE, MM/dd')} -- ${issue}"
else println " -- ${issue}" }
/// A sorter which sorts by date first, then by priority.
def priorityDateSorter = { i1, i2 ->
if (i1.priority == i2.priority) {
def d1 = i1.due ?: new DateTime()
@ -335,25 +460,26 @@ else if (opts.D) {
return d1.compareTo(d2) }
else { return i1.priority - i2.priority }}
// Sort the issues into seperate lists based on their due dates and
// reminders.
/// #### Categorize and sort the issues.
/// Sort the issues into seperate lists based on their due dates and
/// reminders.
allIssues.each { issue ->
// Find the issues that are scheduled for today.
/// * Find the issues that are scheduled for today.
if (issue.scheduled && issue.scheduled < tomorrow) {
scheduledToday << issue }
// Find the issues that are due today or are past due.
/// * Find the issues that are due today or are past due.
else if (issue.due && issue.due < tomorrow) { dueToday << issue }
// Find the issues that are not yet due but have a reminder for today or
// days past.
/// * Find the issues that are not yet due but have a reminder for today or
/// days past.
else if (issue.reminder && issue.reminder < tomorrow) {
reminderToday << issue }
// All the others (not due and no reminder).
/// * All the others (not due and no reminder).
else notDueOrReminder << issue }
// Print the issues
/// #### Print the issues
if (visibleSections.contains('scheduled') && scheduledToday.size() > 0) {
println "Tasks Scheduled for Today"
println "-------------------------"
@ -386,7 +512,7 @@ else if (opts.D) {
println "" }}
// new issues fourth
/// ### Create a New Issue.
else if (opts.n) {
log.trace("Creating a new issue.")
@ -394,17 +520,17 @@ else if (opts.n) {
Issue issue
def sin = System.in.newReader()
// Set the created extended property
/// Set the created extended property.
assignOpts.created = new DateTime()
// Prompt for the different options if they were not given on the command
// line. We will loop until they have entered a valid value. How it works:
// In the body of the loop we will try to read the input, parse it and
// assign it to a variable. If the input is invalid it will throw as
// exception before the assignment happens, the variable will still be
// null, and we will prompt the user again.
/// Prompt for the different options if they were not given on the command
/// line. We will loop until they have entered a valid value. How it works:
/// In the body of the loop we will try to read the input, parse it and
/// assign it to a variable. If the input is invalid it will throw an
/// exception before the assignment happens, the variable will still be
/// null, and we will prompt the user again.
// Prompt for category.
/// Prompt for category.
while(!assignOpts.category) {
try {
print "Category (bug, feature, task): "
@ -415,7 +541,7 @@ else if (opts.n) {
println "Valid options are: \n${Category.values().join(', ')}"
println " (abbreviations are accepted)." } }
// Prompt for the priority.
/// Prompt for the priority.
while (!assignOpts.priority) {
try {
print "Priority (0-9): "
@ -423,13 +549,13 @@ else if (opts.n) {
break }
catch (e) { println "Not a valid value." } }
// Prompt for the issue title. No need to loop as the input does not need
// to be validated.
/// Prompt for the issue title. No need to loop as the input does not need
/// to be validated.
if (!assignOpts.title) {
println "Issue title: "
assignOpts.title = sin.readLine().trim() }
// Prompt for the issue text.
/// Prompt for the issue text.
if (!assignOpts.text) {
assignOpts.text = ""
println "Enter issue text (use EOF to stop): "
@ -444,35 +570,42 @@ else if (opts.n) {
assignOpts.text += line + EOL } }
catch (e) {} }
/// Create the issue.
issue = issuedb.createNewIssue(assignOpts)
println "New issue created: "
println issue }
// last, changes to existing issues
/// ### Change Existing Issues.
else if (assignOpts.size() > 0) {
log.trace("Changing existing issues.")
// We are going to add some extra properties if the status is being changed,
// because we are nice like that.
/// We are going to add some extra properties if the status is being changed,
/// because we are nice like that.
if (assignOpts.status) { switch (assignOpts.status) {
case Status.RESOLVED: assignOpts.resolved = new DateTime(); break
case Status.REJECTED: assignOpts.rejected = new DateTime(); break
default: break }}
/// #### processIssue
/// A local function to handle the changes for one issue.
def processIssue = { issue ->
println issue
/// Walk the assigned options map and set the properties on the issue.
assignOpts.each { propName, value ->
issue[propName] = value
def formattedValue = ExtendedPropertyHelp.format(value)
println " set ${propName} to ${formattedValue}" } }
/// If the user passed `-R`, walk the whole project, including subprojects.
if (opts.R) { issuedb.walkProject(filter, processIssue) }
/// Otherwise, just process the issues in this project.
else {
issuedb.issues.values()
.findAll { filter ? filter.accept(it) : true }
.each(processIssue) }}
/// ### Invalid Input
else {
log.trace("Unknown request.")
cli.usage(); return -1 }}