klog
Version:
Distributed issue tracking
1,410 lines (1,182 loc) • 39.1 kB
text/coffeescript
#!/usr/bin/env coffee
# START HEADER COMMENTS
###
Author: Billy Moon (http://billy.itaccess.org/)
LICENSE:
Copyright (c) 2012 by Billy Moon. All rights reserved.
This module is free software;
you can redistribute it and/or modify it under the MIT license
The LICENSE file contains the full text of the license.
###
# END HEADER COMMENTS
## Modules
fs = require 'fs'
exec = require("child_process").exec
# Underscore Library
_ = require('../lib/underscore-min.js')
# MD5 Library
md5 = require('../lib/md5.js').MD5.hex_md5
# Editor Library
editor = require('../lib/editor.js')
## Functions
parseArgs = ->
## alternative implementation idea based on creating a `command` class
#
# in_array = (needle, haystack)->
# for e in haystack
# if e == needle then return true
# false
# cli = "show --type there -x 'this is' -c 'anything \" goes --here#$#$' --happen --thong -m this goes to the end -t happen"
# cli = "add -t feature -p 2 -m theis is the message%"
# rex = /-(m.+|-?(\S+))(\s+((['"]).+?\5|[^'"-]\S*))?/g
# commands =
# show:
# mandatory: ['m']
# optional: ['x','c']
# class command
# constructor: (cli)->
# m = cli.match /^\S+/
# @cmd = m[0]
# @args = {}
# for i in cli.match rex
# m = i.match /--?(\S+)(\s+(['"]?)(.+)\3)?/
# console.log m
# cmd = m[1]
# if m[4]
# params = m[4]
# else
# params = null
# @args[cmd] = params
# validate: ->
# err = null
# if ! commands[@cmd]
# err = "command: `#{@cmd}` does not exist"
# else
# for e in @args
# if ! in_array e, commands.mandatory
# console.log "bad:" + e
# else
# console.log "good: " + e
# err
# Command line options
options =
s:'state'
m:'message'
e:'editor'
t:'type'
p:'priority'
l:'label'
# simple toggles, don't consume next argument
switches =
a:'all'
d:'debug'
# x:'exit'
f:'force'
r:'return'
x:'plain' # no colours in output, and lean towards formatting suited to scripts
args = process.argv
o = {_:[],$0:[]}
validOptions = []
for k, v of options
validOptions.push v
i = -2
na = false # next argument: false/opt/flag
for arg in args
if m = arg.match /^--(.+?)(=(.+))?$/
na = m[1]
o[m[1]] = m[3] || true
else if m = arg.match /^-(.+?)(=(.+))?$/
if na = options[m[1]]
if na == 'message'
o[na] = m[3] ? [m[3]] | ['']
else
o[na] = m[3] || true
else if switches[m[1]]
na = false
o[switches[m[1]]] = m[3] || true
else
print 'Unknown flag: '+m[1]
exit 1
else if ++i > 0 # ignore first two args which are node and app
if na == 'message'
o.message = [arg]
else if na != false
o[na] = arg
else
if o.message
o.message.push arg
else
o._.push arg
na = false
else
o['$0'].push arg
if o.message
o.message = o.message.join ' '
return o
## Utility functions.
# Pad a string (with 0 or specified)
pad = (e,t,n)->
n = n || "0"
t = t || 2
while (""+e).length<t
e=n+e
e
# return date in format yyyy-mm-dd_hh-ii-ss
getDate = ->
c = new Date()
return c.getFullYear()+"-"+pad(c.getMonth()+1)+"-"+pad(c.getDate())+"_"+c.toLocaleTimeString().replace(/\D/g,'-')+"."+pad(c.getMilliseconds(),3)
asDate = (datestring) ->
new Date datestring.replace(/_/,'T').replace(/T(.+)-(.+)-/,"T$1:$2:")
# Generate a system UID. This should be created with the username and
# time included, such that collisions when running upon multiple systems
# are unlikely.
randomUID = ->
# The values that feed into the filename.
$uid = opts.date+"."+opts.email
$uid = md5 $uid
$uid = $uid.replace /(.{4}).+/, "$1"
return $uid
# Find and return an array of hashes, one for each existing bug.
getBugs = ->
if ! opts.path
print """
This directory does not appear to have Klogs on!
Put them on with:
klog init
or try `klog help` for more info
"""
exit()
files = fs.readdirSync "#{opts.path+opts.store}"
files.sort()
$results = []
$number = 1
for file in files
if file.match /\.log$/
$status = 'open'
buffer = fs.readFileSync "#{opts.path+opts.store}#{file}"
lines = buffer.toString().split /[\r\n]+/
# print content
$priority = 0
$modified = null
$body = []
for line in lines
if m = line.match /^Title: (.*)/
$title = m[1]
else if m = line.match /^Type: (.*)/
$type = m[1]
else if m = line.match /^Priority: (.*)/
$priority = m[1]
else if m = line.match /^Added: (.*)/
$added = m[1]
else if m = line.match /^Modified: (.*)/
$modified = m[1]
else if m = line.match /^Author: (.*)/
$author = m[1]
else if m = line.match /^UID: (.*)/
$uid = m[1]
else if m = line.match /^Status: (.*)/i
$status = m[1]
else
$body.push "\r\n"+line
if ! $modified
$modified = $added
$results.push
file: file
body: $body
number: $number++
uid: $uid
status: $status
type: $type
priority: $priority
title: $title
added: $added
modified: $modified
author: $author || 'unspecified'
return $results
# Print to console
print = (txt) ->
console.log txt
# Get the data for a given bug, either by number of UID.
getBugByUIDORNumber = ($arg) ->
# Get all bugs.
$bugs = getBugs()
# For each one.
for $possible in $bugs
# If the argument was NNNN then look for that bug number.
# strip lead bug identifier
$arg = $arg.replace /^%/, ''
if m = $arg.match /^([0-9]{1,3})$/i
$bug = $possible if parseInt(m[1]) == $possible.number
else
# Otherwise look for it by UID
$bug = $possible if $arg.toLowerCase() == $possible.uid.toLowerCase()
if $bug
return $bug
print "Last resort, trying to search (open issues) for: #{glob.clrs.yellow}#{$arg}#{glob.clrs.reset}"
bug = cmd.search
return: true
terms: $arg
state: 'open'
all: false
if bug
hl = if bug.status == 'open' then glob.clrs.green else glob.clrs.red
cb = glob.clrs.bright
ch = glob.clrs.yellow
cr = glob.clrs.reset
print "Found: %#{hl}#{bug.uid}#{glob.clrs.reset} [#{ch}#{bug.status}#{cr}] [#{ch+cb}#{bug.type}#{cr}] #{bug.title}"
return bug
# else
# print bug
print "Bug not found!!"
exit 1
# Exit app with error code
exit = (code) ->
# print "#{glob.clrs.red}EXIT ~ with code: #{glob.clrs.bright}#{code}#{glob.clrs.reset}"
# only if we are not in server mode
if ! opts.server
process.exit code
# Open the given file with either the users editor, the systems editor,
# or as a last resort vim or notepad depending on platform.
editFile = (file) ->
# Open the editor
$editor = if opts.args.editor then opts.args.editor else if process.env.EDITOR then process.env.EDITOR else if opts.win then "notepad" else "vim"
editor file, {}
# exec "#{$editor} #{file}"
# Remove the "# klog: " prefix from the given file.
remove_comments = ($file) ->
# Open the source file for reading.
try
buffer = fs.readFileSync $file
catch e
print "Failed to open #{$file}"
exit
content = buffer.toString().replace /^# klog:.*(\r\n|\n|\r)/mg, ''
# Write the contents, removing any lines matching our marker-pattern
fs.writeFileSync $file, content
# Show the usage of this script and exit.
usage = ->
print '''
klog [options] sub-command [args]
Available sub-commands:
add - Add a new bug.
append - Append text to an existing bug.
Set type with -t, and use `.` as message for no message
close - Change an open bug to closed.
closed - List all currently closed bugs.
edit - Allow a bug to be edited.
delete - Allow a bug to be deleted.
destroy - Destroys the whole klog storage folder (including all issue data!)
init - Initialise the system.
list|search - Display existing bugs.
open - List all currently open bugs.
reopen - Change a closed bug to open.
view - Show all details about a specific bug.
server - HTTP server displays bugs, and accepts commands
Options:
-f, --force - no confirmation when deleting
-t, --type - issue type (default:bug) i.e. feature/enhance/task
-m, --message - Use the given message rather than spawning an editor.
-s, --state - Restrict matches when searching (open/closed).
-a, --all - Search everywhere (type, and message), not just the title
-p, --priority - Set the priority (`.` is replaced with `-`, so `.3` will result in `-3`)
'''
# -e, --editor - Specify which editor to use.
exit 0
hook = (action, file) ->
if hooks[action]
hooks[action].run file
# Change the statues of an existing bug. Valid statuses are
# "open" and "closed".
changeBugState = ($value, $state) ->
# Ensure the status is valid.
if ! $state.match /^(open|closed)$/i
print "Invalid status #{$state}"
exit 1
# Get the bug.
$bug = getBugByUIDORNumber $value
# Ensure the bug isn't already in the specified state.
if $bug.status == $state
print "The bug is already #{$state}!\r\n"
exit 1
# Now write out the new status section.
content = """\r\n
Modified: #{opts.date}
Status: #{$state}
"""
fs.appendFileSync opts.path+opts.store+$bug.file, content
add = asDate $bug.added
mod = asDate opts.date
print "("+Math.round(( (mod - add) / 1000 / 60 / 60 )*100)/100 + " hours after issue was added)"
# print $bug
# If there is a hook, run it.
hook $state, $bug.file
get_user_details = (callback) ->
if opts.user && opts.email
callback()
else
exec 'git config --get user.email', (se,so,e) ->
if so.length
opts.email = so.replace /[\r\n]+/, ''
exec 'git config --get user.name', (se,so,e) ->
if so.length
opts.user = so.replace /[\r\n]+/, ''
else
opts.user = opts.email.replace /@.+$/, ''
callback()
else
print """
Tried to get email address from Git, but could not determine using:
\r\n\tgit config --get user.email\r\n
It might be a good idea to set it with:
\r\n\tgit config etc...\r\n
"""
print "Please enter your details... (leave blank to abort)"
stdin = process.openStdin()
process.stdout.write "Name: "
stdin.addListener "data", (d) ->
if ! opts.user && opts.user = d.toString().trim()
process.stdout.write "Email: "
else if ! opts.email && opts.email = d.toString().trim()
process.stdin.destroy()
callback()
else
print "Error: tried everything, still no name and email!"
exit 1
get_confirmation = (callback, message) ->
stdin = process.openStdin()
process.stdout.write "Are you sure? [yep/nope]: "
stdin.addListener "data", (d) ->
if d.toString().match /y(e(p|s|ah))?/i
callback()
process.stdin.destroy()
else
if message
print message
process.stdin.destroy()
exit 1
get_required = (items, final) ->
stdin = process.stdin
if ! items?.length
stdin.pause()
if ! opts.command.needs?.length
delete opts.command.needs
final()
else
if ! opts.args[items[0]]
item = items.shift()
if ! opts.args[item] && item
process.stdout.write "#{item}: "
stdin.resume()
stdin.once 'data', (d) ->
stdin.pause()
line = d.toString().trim()
if line
opts.command.args[item] = line
else
items.unshift item
get_required items, final
## Handlers for the commands.
cmd = {}
# Add a new bug.
cmd.add = (args) ->
print args
# Make a "random" filename, with the same UID as the content.
$uid = randomUID()
$title = args.title
$type = args.type || 'bug'
$priority = args.priority || '0'
$priority = $priority.replace /\./, '-'
opts.args.file = "#{opts.date}.#{$uid}.log";
# Write our template to it
opts.args.template = """
UID: #{$uid}
Type: #{$type}
Priority: #{$priority}
Title: #{$title}
Added: #{opts.date}
Author: #{opts.user}
\r\n
"""
# Status: open\r\n\r\n
# If we were given a message, add it to the file, and return without
# invoking the editor.
if args.message
fs.writeFileSync opts.path+opts.store+opts.args.file, opts.args.template + args.message
print "added issue %#{glob.clrs.yellow}#{$uid}#{glob.clrs.reset}"
# If there is a hook, run it.
hook "add", opts.args.file
return
# Otherwise add the default text, and show it in an editor.
# (ending newline helps in stripping the comments out later)
else
opts.args.template += """
# klog:
# klog: Enter your bug report here; it is better to write too much than
# klog: too little.
# klog:
# klog: Lines beginning with "# klog:" will be ignored, and removed,
# klog: this file is saved.
# klog:\r\n
"""
fs.writeFileSync opts.args.file, opts.args.template
# Open the file in the users' editor.
editFile opts.args.file
# Once it was saved remove the lines that mention "# klog: "
remove_comments opts.args.file
print "added issue %#{glob.clrs.yellow}#{$uid}#{glob.clrs.reset}"
# If there is a hook, run it.
hook "add", opts.args.file
# Open an editor with a new block appended to the end of the file.
# This mostly means:
# 1. find the file associated with a given bug.
# 2. Append the new text.
# 3. Allow the user to edit that file.
cmd.append = (args) ->
# Ensure we know what we're operating upon
if ! args.id
print """
You must specify a bug to append to, either by the UID, or via the number.
For example to append text to bug number 3 you'd run:
\r\n\tklog append 3\r\n
"""
exit 1
# Get the bug
$bug = getBugByUIDORNumber args.id
# If we were given a message add it, otherwise spawn the editor.
# redundant when the message argument is required
if args.message || args.type
$out = "\r\n\r\nModified: #{opts.date}\r\n"
if args.type
$out += "Type: #{args.type}\r\n"
if args.priority
$out += "Priority: #{args.priority.replace /[\.]/, '-'}\r\n"
if args.message != '.'
$out += "#{args.message||''}"
fs.appendFileSync opts.path+opts.store+$bug.file, $out
return
else
$out = "\r\nModified: #{opts.date}\r\n\r\n"
fs.appendFileSync opts.path+opts.store+$bug.file, $out
# Allow the user to make the edits.
editFile opts.path+opts.store+$bug.file
## BROKEN due to separate process ##
# # Once it was saved remove the lines that mention "# klog: "
# remove_comments opts.store+$bug.file
# If there is a hook, run it.
hook "append", $bug.file
# Output a HTML page for the bugs.
cmd.html = (args) ->
# Get all bugs.
$bugs = getBugs()
# Open + closed bugs.
$open = []
$closed = []
for $b in $bugs
if $b.status.match /open/i
$open.push $b
else
$closed.push $b
# Counts
$open_count = $open.length
$closed_count = $closed.length
out = """
<!DOCTYPE HTML>
<html lang="en-US">
<head>
<meta charset="UTF-8">
<title>klog : issue tracking and time management</title>
<style type='text/css'>
/*
This is the<a href="#" class="button default inline">Default</a> action!
<a href="#" class="button blue">Blue</a>
*/
.button {
margin: 0 15px 15px 0;
font-family: 'Lucida Grande', 'Helvetica Neue', sans-serif;
font-size: 13px;
display: inline-block;
background-color: #f5f5f5;
background-image: -webkit-linear-gradient(top,#f5f5f5,#f1f1f1);
background-image: -moz-linear-gradient(top,#f5f5f5,#f1f1f1);
background-image: -ms-linear-gradient(top,#f5f5f5,#f1f1f1);
background-image: -o-linear-gradient(top,#f5f5f5,#f1f1f1);
background-image: linear-gradient(top,#f5f5f5,#f1f1f1);
color: #444;
border: 1px solid #dcdcdc;
-webkit-border-radius: 2px;
-moz-border-radius: 2px;
border-radius: 2px;
cursor: default;
font-size: 11px;
font-weight: bold;
text-align: center;
height: 27px;
line-height: 27px;
min-width: 54px;
padding: 0 8px;
text-decoration: none;
}
.button.inline {
margin: 0 .2em 0 .5em;
}
.button:hover {
background-color: #F8F8F8;
background-image: -webkit-linear-gradient(top,#f8f8f8,#f1f1f1);
background-image: -moz-linear-gradient(top,#f8f8f8,#f1f1f1);
background-image: -ms-linear-gradient(top,#f8f8f8,#f1f1f1);
background-image: -o-linear-gradient(top,#f8f8f8,#f1f1f1);
background-image: linear-gradient(top,#f8f8f8,#f1f1f1);
border: 1px solid #C6C6C6;
color: #333;
-webkit-box-shadow: 0px 1px 1px rgba(0,0,0,.1);
-moz-box-shadow: 0px 1px 1px rgba(0,0,0,.1);
box-shadow: 0px 1px 1px rgba(0,0,0,.1);
text-decoration: none;
cursor: pointer;
}
/* blue */
.button.blue {
background-color: #4D90FE;
background-image: -webkit-linear-gradient(top,#4d90fe,#4787ed);
background-image: -moz-linear-gradient(top,#4d90fe,#4787ed);
background-image: -ms-linear-gradient(top,#4d90fe,#4787ed);
background-image: -o-linear-gradient(top,#4d90fe,#4787ed);
background-image: linear-gradient(top,#4d90fe,#4787ed);
border: 1px solid #3079ED;
color: white;
}
.button.blue:hover {
border: 1px solid #2F5BB7;
background-color: #357AE8;
background-image: -webkit-linear-gradient(top,#4d90fe,#357ae8);
background-image: -moz-linear-gradient(top,#4d90fe,#357ae8);
background-image: -ms-linear-gradient(top,#4d90fe,#357ae8);
background-image: -o-linear-gradient(top,#4d90fe,#357ae8);
background-image: linear-gradient(top,#4d90fe,#357ae8);
-webkit-box-shadow: 0 1px 1px rgba(0,0,0,.1);
-moz-box-shadow: 0 1px 1px rgba(0,0,0,.1);
box-shadow: 0 1px 1px rgba(0,0,0,.1);
}
/* red */
.button.red {
background-color: #D14836;
background-image: -webkit-linear-gradient(top,#dd4b39,#d14836);
background-image: -moz-linear-gradient(top,#dd4b39,#d14836);
background-image: -ms-linear-gradient(top,#dd4b39,#d14836);
background-image: -o-linear-gradient(top,#dd4b39,#d14836);
background-image: linear-gradient(top,#dd4b39,#d14836);
border: 1px solid transparent;
color: white;
text-shadow: 0 1px rgba(0, 0, 0, 0.1);
}
.button.red:hover {
background-color: #C53727;
background-image: -webkit-linear-gradient(top,#dd4b39,#c53727);
background-image: -moz-linear-gradient(top,#dd4b39,#c53727);
background-image: -ms-linear-gradient(top,#dd4b39,#c53727);
background-image: -o-linear-gradient(top,#dd4b39,#c53727);
background-image: linear-gradient(top,#dd4b39,#c53727);
}
/* green */
.button.green {
background-color: #3D9400;
background-image: -webkit-linear-gradient(top,#3d9400,#398a00);
background-image: -moz-linear-gradient(top,#3d9400,#398a00);
background-image: -ms-linear-gradient(top,#3d9400,#398a00);
background-image: -o-linear-gradient(top,#3d9400,#398a00);
background-image: linear-gradient(top,#3d9400,#398a00);
border: 1px solid #29691D;
color: white;
text-shadow: 0 1px rgba(0, 0, 0, 0.1);
}
.button.green:hover {
background-color: #368200;
background-image: -webkit-linear-gradient(top,#3d9400,#368200);
background-image: -moz-linear-gradient(top,#3d9400,#368200);
background-image: -ms-linear-gradient(top,#3d9400,#368200);
background-image: -o-linear-gradient(top,#3d9400,#368200);
background-image: linear-gradient(top,#3d9400,#368200);
border: 1px solid #2D6200;
text-shadow: 0 1px rgba(0, 0, 0, 0.3);
}
</style>
<style type='text/css'>
body{
font-family: century gothic;
}
.bug {
background-color: #F7F7F7;
border: 5px solid #666666;
border-radius: 0.5em 0.5em 0.5em 0.5em;
margin: 0.5em 0;
padding: 0.3em 1em;
}
.bug h3{
font-size: 2em;
margin: 0.2em 0;
}
#command-intro {
padding-left: 0.5em;
width: 3.4em;
}
input {
background-color: black;
border: medium none;
color: silver;
float: left;
height: 2em;
margin: 0;
padding: 0;
font-size: 1em;
}
h1, h2, h3, h4, h5, h6, p, ul{
clear: both;
}
#command{
width: 40em;
}
#execute{
border-left: 1px solid red;
padding: 0 0.3em;
}
ul.nav{
padding-top: 1em;
}
ul.nav li{
float: left;
list-style-type: none;
margin: 0 1em 0 -1em;
padding: 0;
}
ul.actions li{
float: left;
list-style-type: none;
}
ul.actions{
padding: 0;
}
ul {
margin: 0 0 1em;
padding: 0 1em;
}
ul.attributes {
color: #666666;
list-style-type: circle;
}
.clear{
clear: both;
}
form{
border: 5px solid #666666;
border-radius: 0.3em 0.3em 0.3em 0.3em;
height: 2em;
width: 49.05em;
}
</style>
</head>
<body onload="document.getElementById('command').focus()">
<h1>Klog : distributed issue tracking</h1>
<form action='.' method='POST'>
<input type="text" value="$ klog" readonly="readonly" name="intro" id="command-intro">
<input type="text" name="command" id="command">
<input type="submit" id="execute" value="execute!">
</form>
<ul class='nav'>
<li><a href='#open' class='button'>#{$open_count} : open bugs</a></li>
<li><a href='#closed' class='button'>#{$closed_count} : closed bugs</a></li>
</ul>
<hr class='clear' />
<a name='open'></a>
<h2 id="open">Open bugs</h2>
"""
for $b in $open
out += """
<div class='bug'>
<h3>#{$b.title}</h3>
<ul class='attributes'>
<li><strong>UID</strong>: #{$b.uid}</li>
<li><strong>Added</strong>: #{$b.added}</li>
<li><strong>Author</strong>: #{$b.author}</li>
<li><strong>Type</strong>: #{$b.type}</li>
<li><strong>Priority</strong>: #{$b.priority}</li>
</ul>
<p>#{$b.body.join "<br>\r\n<br>\r\n"}</p>
<hr>
<ul class='actions'>
<li><a href='./?command=close #{$b.uid}' class='button blue'>Close</a></li>
<li><a href='./?command=delete #{$b.uid} -f' class='button red' onclick='return confirm("Do you really want to delete this item?")'>Delete</a></li>
</ul>
<br class='clear' />
</div>
"""
out += """
<h2 id="closed">Closed bugs</h2>
"""
for $b in $closed
out += """
<div class='bug'>
<h3>#{$b.title}</h3>
<ul class='attributes'>
<li><strong>UID</strong>: #{$b.uid}</li>
<li><strong>Added</strong>: #{$b.added}</li>
<li><strong>Author</strong>: #{$b.author}</li>
<li><strong>Type</strong>: #{$b.type}</li>
<li><strong>Priority</strong>: #{$b.priority}</li>
</ul>
<p>#{$b.body.join "<br>\r\n<br>\r\n"}</p>
<hr>
<ul class='actions'>
<li><a href='./?command=reopen #{$b.uid}' class='button green'>Re-open</a></li>
<li><a href='./?command=delete #{$b.uid} -f' class='button red' onclick='return confirm("Do you really want to delete this item?")'>Delete</a></li>
</ul>
<br class='clear' />
</div>
"""
out += """
<div id="foot">
Generated by <a href="http://billymoon.github.com/klog/">klog</a>.
</div>
</body>
</html>
"""
if args.return
return out
else
print out
# Search the existing bugs.
# Here search means "match against title and status". Either of which
# is optional.
cmd.search = (args) ->
# The search terms, if any.
$terms = args.terms
# Get all available bugs.
$bugs = getBugs()
# The state of the bugs the user is interested in.
$state = args.state || 'all'
# The type of the bugs the user is interested in.
$type = args.type || "all"
# The priority of the bugs the user is interested in.
$priority = args.priority || "all"
# catch unset priority, and reset to all
if $priority == true then $priority = "all"
if m = $priority.match /(.+)([+-])$/
$priority = m[1]
direction = m[2]
else direction = null
# print "will search for `#{$terms}` with state `#{$state}` and type `#{$type}`"
found = []
# For each bug
for $bug in $bugs
# If the user is being specific about status then
# skip ones that don't match, as this is cheap.
if $state != "all" and $state.toLowerCase() != $bug.status.toLowerCase()
continue
# If the user is being specific about type then
# skip ones that don't match
if $type != "all" and $type.toLowerCase() != $bug.type.toLowerCase()
continue
# If the user is being specific about priority then
# skip ones that don't match
if ($priority+'').match /\./
$priority = 0-$priority*10
# print [$priority, direction, parseInt($bug.priority)]
$bug.priority = parseInt $bug.priority
if $priority != "all"
$priority = parseInt $priority
if direction == '+' and $priority > $bug.priority
continue
else if direction == null and $priority != $bug.priority
continue
else if direction == '-' and $priority < $bug.priority
continue
# If there are search terms then search the title.
# All terms must match.
$match = 1
$b_body = $bug.body.join('').replace(/(\\.|[^\w\s])/g,'')
# print $b_body
pool = if args.all then $bug.title+$bug.type+$b_body else $bug.title
if args.terms # there are $terms
for $term in $terms.split /[ \t]+/
if ! pool.match new RegExp $term, 'i'
$match = 0
# If we didn't find a match move on.
continue unless $match
found.push $bug
if args.return && found.length == 1
return found[0]
else
output_cli found, 'priority'
# output bugs list to command line, optionally with sorting
output_cli = (bugs,sort)->
if sort == 'priority'
bugs.sort (a,b)-> b.priority - a.priority
else if sort == 'added'
bugs.sort (a,b)->
bb = parseInt b.added.replace /\D/g,''
aa = parseInt a.added.replace /\D/g,''
bb - aa
else if sort == 'modified'
bugs.sort (a,b)->
bb = parseInt b.modified.replace /\D/g,''
aa = parseInt a.modified.replace /\D/g,''
bb - aa
# console.log bugs
out = []
for bug in bugs
# Otherwise show a summary of the bug.
# print sprintf "%-4s %s %-8s %-9s %s", "#".$b_number, $bug.uid, "[".$bug.status."]", "[".$bug.type."]", $bug.title . "\r\n";
# removed number: ##{$b_number}
hl = if bug.status == 'open' then glob.clrs.green else glob.clrs.red
cb = glob.clrs.bright
ch = glob.clrs.yellow
cr = glob.clrs.reset
pr = if bug.priority > 1 then glob.clrs.bright+glob.clrs.yellow else if bug.priority > 0 then glob.clrs.yellow else if bug.priority < -1 then glob.clrs.gunmetal else glob.clrs.silver
out.push "%#{hl}#{bug.uid}#{glob.clrs.reset} [#{pr}#{pad (bug.priority+'').replace(/^([1-9])/,'+$1'), 2, ' '}#{cr}] [#{ch}#{bug.status}#{cr}] [#{ch+cb}#{bug.type}#{cr}] #{bug.title}"
print out.join "\r\n"
# View a specific bug.
# This means:
# 1. Find the file associated with the bug.
# 2. Open it and print it to the console.
cmd.view = (args) ->
$value = args.id
# Ensure we know what we're operating upon
if ! $value # there is not a $value
print "You must specify a bug to view, either by the UID, or via the number.\r\n"
print "\r\nFor example to view bug number 3 you'd run:\r\n"
print "\tklog view 3\r\n\r\n";
print "Maybe a list of open bugs will help you:\r\n\r\n"
cmd.search()
print "\r\n"
exit 1
# Get the bug.
$bug = getBugByUIDORNumber $value
# Show it to the console
buffer = fs.readFileSync opts.path+opts.store + $bug.file
print buffer.toString().replace /^(\w+): /gm, "#{glob.clrs.yellow}$1#{glob.clrs.reset}: "
# Close a given bug.
cmd.close = (args) ->
# Get the bug.
$value = args.id
# Ensure we know what we're operating upon
if ! $value # has $value
print """
You must specify a bug to close, either by the UID, or via the number.
For example to close bug number 3 you'd run:
\r\n\tklog close 3\r\n\r\n
"""
exit 1
changeBugState $value, "closed"
# Reopen a bug.
cmd.reopen = (args) ->
# Get the bug.
$value = args.id
# Ensure we know what we're operating upon
if ! $value
print """
You must specify a bug to reopen, either by the UID, or via the number.
For example to reopen bug number 3 you'd run:
\r\n\tklog reopen 3
"""
exit 1
changeBugState $value, "open"
# Allow a bug to be updated.
# This mostly means:
# 1. find the file associated with a given bug.
# 2. Allow the user to edit that file.
cmd.edit = (args) ->
$value = args.id
# Ensure we know what we're operating upon
if ! $value
print """
You must specify a bug to edit, either by the UID, or via the number.
For example to edit bug number 3 you'd run:
\r\n\tklog edit 3\r\n\r\n
"""
exit 1
# Find the bug.
$bug = getBugByUIDORNumber $value
# Edit the file the bug is stored in.
editFile opts.path+opts.store+$bug.file
# If there is a hook, run it.
hook "edit", $bug.file
# Allow a bug to be deleted.
# This mostly means:
# 1. find the file associated with a given bug.
# 2. delete that file.
cmd.delete = (args) ->
cmd.view opts.command.args
do_delete = ->
$value = args.id
# Ensure we know what we're operating upon
if ! $value
print """
You must specify a bug to delete, either by the UID, or via the number.
For example to delete bug number 3 you'd run:
\r\n\tklog delete 3\r\n
"""
exit 1
# Find the bug.
$bug = getBugByUIDORNumber $value
# Delete the file the bug is stored in.
$file = $bug.file
fs.unlinkSync opts.path+opts.store+$file
# If there is a hook, run it.
hook "delete", $bug.file
if ! args.force
print "About to delete this bug..."
get_confirmation ->
do_delete()
, "Phew, that was close!"
else
do_delete()
# Inititalise a new .klog directory.
cmd.init = ->
if ! fs.existsSync opts.store
fs.mkdirSync opts.store
opts.path = process.cwd()+'/'
print "#{glob.clrs.gunmetal}Now you have klogs on#{glob.clrs.reset}#{glob.clrs.red}!#{glob.clrs.reset}"
cmd.setup()
else
print "There is already a .klog/ directory present here"
exit 1
cmd.destroy = (args) ->
if args.force
exec "rm -Rf #{opts.path+opts.store}"
else
print "This will destroy all issues. You must force this with `-f`."
cmd.setup = ->
if opts.user && opts.email
settings = """
{
"user":"#{opts.user || 'John Doe'}",
"email":"#{opts.email || 'john@thedoughfactory.com'}"
}
"""
fs.writeFileSync "#{opts.path+opts.store}.gitignore","local"
fs.mkdirSync "#{opts.path+opts.store}local"
fs.writeFileSync "#{opts.path+opts.store}local/settings.json", settings
print "Wrote settings to local file: #{opts.path+opts.store}local/settings.json\r\n\r\n#{settings}\r\n"
else
get_user_details cmd.setup
cmd.server = ->
opts.server = true
port = 1234
http = require 'http'
qs = require 'querystring'
url = require 'url'
command = (data) ->
# POST = JSON.parse POST
# opts.args._.push data.command.split ' '
if data.command
args = data.command.trim().split ' '
print args
while process.argv.length > 2
process.argv.pop()
_.each args, (v) ->
process.argv.push v
opts.date = getDate()
main()
http.createServer (req, res) ->
out_html = ->
# print req.body
res.writeHead 200,
'Content-Type': 'text/html'
opts.command.args.return = true
res.end cmd.html opts.command.args
if req.method == 'POST'
body = ''
req.on 'data', (data) ->
body += data
req.on 'end', ->
POST = qs.parse body
command POST
out_html()
else if req.method == 'GET'
url_parts = url.parse req.url, true
# print url_parts.query
command url_parts.query
out_html()
.listen port
print "Serving `#{opts.path}` at http://127.0.0.1:#{port}/"
# parse opts.args and return command object
# should validate required options, but not their values
get_command = ->
out =
args: []
get_id = ->
if id = opts.args._.shift()
opts.args.id = id.replace /^%/, ''
# valid commands defined as obejct tree
commands =
add:
required: ['title','message']
valid: ['type','priority','label']
args: ->
if opts.args._.length
opts.args.title = opts.args._.join ' '
delete:
required: ['id']
valid: ['force']
args: ->
if id = opts.args._.shift()
opts.args.id = id.replace /^%/, ''
help: {}
init: {}
list:
valid: ['type','state','terms','all','return','priority']
args: ->
if subcommand == 'search'
opts.args.all = true
if opts.args._.length
opts.args.terms = opts.args._.join ' '
console.log opts.args.terms
open:
required: ['state'] # auto populated
valid: ['type','priority']
args: ->
opts.args.state = 'open'
closed:
required: ['state'] # auto populated
valid: ['type','priority']
args: ->
opts.args.state = 'closed'
view:
required: ['id']
args: get_id
edit:
required: ['id']
valid: ['editor']
args: get_id
append:
required: ['id','message']
valid: ['type','priority']
args: get_id
reopen:
required: ['id']
args: get_id
close:
required: ['id']
args: get_id
html: {}
server: {}
destroy:
valid: ['force']
for command of commands
if ! commands[command].valid
commands[command].valid = []
commands[command].valid.push 'plain' # (no colours in output, and lean towards formatting suited to scripts)
# print commands
commands.search = commands.list
# figure out what the command is, or assign `help`
subcommand = opts.args._.shift() || 'help' # if no arguments
command = commands[subcommand] || subcommand = 'help' # if invalid argument
out.name = subcommand
# parse remaining arguments according to subcommand
if command.args then command.args()
# required options
if command.required
for requirement in command.required
if has = opts.args[requirement]
out.args = {} unless out.args
out.args[requirement] = has
else
out.needs = [] unless out.needs
out.needs.push requirement
# optional options
if command.valid
for valid in command.valid
if has = opts.args[valid]
out.args = {} unless out.args
out.args[valid] = has
# superfluous options
for x of opts.args
if x != '$0' && x != '_'
if ! out.args[x]
rejects = [] unless rejects
rejects.push x
if rejects
message = "Error: unsupported option used"
print message
exit 1
return out
# The main routine ************************************************************************************************
main = ->
# Parse the command line options.
opts.args = parseArgs()
# Generate command from arguments
opts.command = get_command()
opts.command.name = opts.command.name.replace /^(open|closed|list)$/, 'search'
# override colours if `plain` is chosen (probably should implement colours as plugin)
if opts.command.args.plain
for clr of glob.clrs
glob.clrs[clr] = ""
get_required opts.command.needs, ->
# process.stdout.write "#{glob.clrs.red+glob.clrs.bright}Command: "
# print opts.command
# process.stdout.write "#{glob.clrs.reset}"
# temporary fix of args
opts.args._.unshift opts.command.name
if opts.args.debug
print opts.args
if opts.args.exit
exit 0
# Ensure we received an argument.
if opts.args.help || ! opts.args._.length
usage()
exit 1
else
opts.cmd = opts.args._.shift()
if cmd[opts.command.name]
cmd[opts.command.name] opts.command.args
else
usage()
# Globals
opts =
ext: 'log' # file extension for data files
date: getDate()
store: '.klog/'
win: process.platform == 'win32'
# set project path
path = process.cwd().split /\//
for folder in path
sep = if opts.win then "\\" else "/"
tpath = (path.join sep)+sep
if fs.existsSync "#{tpath+opts.store}"
opts.path = tpath
break
path.pop()
# Read settings (including `user` and `email`)
if fs.existsSync "#{opts.path+opts.store}/local/settings.json"
buffer = fs.readFileSync "#{opts.path+opts.store}/local/settings.json"
settings = JSON.parse buffer.toString()
opts = _.extend opts, settings
glob = {}
glob.clrs = {
bright:"\u001b[1m",
red:"\u001b[31m",
green:"\u001b[32m",
blue:"\u001b[34m",
cyan:"\u001b[36m",
magenta:"\u001b[35m",
yellow:"\u001b[33m",
black:"\u001b[30m",
gunmetal:"\u001b[30m\u001b[1m",
silver:"\u001b[37m",
white:"\u001b[37m\u001b[1m",
back_red:"\u001b[41m",
back_green:"\u001b[42m",
back_blue:"\u001b[44m",
back_cyan:"\u001b[46m",
back_magenta:"\u001b[45m",
back_yellow:"\u001b[43m",
back_black:"\u001b[40m",
back_silver:"\u001b[47m",
reset:"\u001b[m"
}
# get hooks and add them to the glogal hook object
hooks = {}
if fs.existsSync "#{opts.path+opts.store}hooks"
fs.readdirSync("#{opts.path+opts.store}hooks").forEach (file) ->
hooks[file.replace /\.\w+$/,''] = require "#{opts.path+opts.store}hooks/#{file}"
# fire it up
main()