#!/usr/bin/perl
## ----------------------------------------------------------------------------
# cil is a Command line Issue List
# Copyright (C) 2008 Andrew Chilton
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
## ----------------------------------------------------------------------------
use strict;
use warnings;
use Data::Dumper;
use Getopt::Mixed "nextOption";
use Digest::MD5 qw(md5_hex);
use File::Touch;
use File::Glob ':glob';
use File::Basename;
use File::Slurp qw(read_file write_file);
use CIL;
use CIL::Issue;
use CIL::Comment;
use CIL::Attachment;
## ----------------------------------------------------------------------------
# constants
use constant VERSION => '0.2.1';
my @IN_OPTS = (
'p=s', # p = path
'path>p', # for 'add'
'f=s', # f = filename
'filename>f', # for 'extract'
'help',
'version',
);
my %BOOLEAN_ARGS = (
help => 1,
version => 1,
);
my $gan = $ENV{GIT_AUTHOR_NAME} || 'Your Name';
my $gae = $ENV{GIT_AUTHOR_EMAIL} || 'you@example.org';
my $new_issue_text = <<"EOF";
Summary :
Status : New
CreatedBy : $gan <$gae>
AssignedTo : $gan <$gae>
Label :
Description...
EOF
my $add_comment_text = <<"EOF";
CreatedBy : $gan <$gae>
Description...
EOF
## ----------------------------------------------------------------------------
# main program
{
my $args = get_options(\@IN_OPTS, \%BOOLEAN_ARGS);
# do the version and help
if ( exists $args->{version} ) {
print "cil version ".VERSION."\n";
exit;
}
if ( exists $args->{help} ) {
usage();
exit;
}
# make sure that the command given is valid
Getopt::Mixed::abortMsg('specify a command')
if @ARGV == 0;
my $command = shift @ARGV;
no strict 'refs';
if ( not defined &{"cmd_$command"} ) {
Getopt::Mixed::abortMsg("'$command' is not a valid cil command.");
}
my $cil = CIL->new();
&{"cmd_$command"}($cil, $args, @ARGV);
}
## ----------------------------------------------------------------------------
# commands
sub cmd_init {
my ($cil, $args) = @_;
my $path = $args->{p} || '.'; # default path is right here
# error if $path doesn't exist
unless ( -d $path ) {
fatal("path '$path' doesn't exist");
}
# error if issues/ already exists
my $issues_dir = "$path/issues";
if ( -d $issues_dir ) {
fatal("issues directory '$issues_dir' already exists, not initialising issues");
}
# error if .cil already exists
my $config = "$path/.cil";
if ( -f $config ) {
fatal("config file '$config' already exists, not initialising issues");
}
# try to create the issues/ dir
unless ( mkdir $issues_dir ) {
fatal("Couldn't create '$issues_dir' directory: $!");
}
# create a .cil file here also
unless ( touch $config ) {
rmdir $issues_dir;
fatal("couldn't create a '$config' file");
}
# add a README.txt so people know what this is about
unless ( -f "$issues_dir/README.txt" ) {
write_file("$issues_dir/README.txt", <get_issues();
if ( @$issues ) {
foreach my $issue ( sort { $a->Inserted cmp $b->Inserted } @$issues ) {
separator();
display_issue_headers($cil, $issue);
}
separator();
}
else {
msg('no issues found');
}
}
sub cmd_summary {
my ($cil) = @_;
check_paths($cil);
# find all the issues
my $issues = $cil->get_issues();
if ( @$issues ) {
separator();
foreach my $issue ( @$issues ) {
display_issue_summary($cil, $issue);
}
separator();
}
else {
msg('no issues found');
}
}
sub cmd_show {
my ($cil, undef, $issue_name) = @_;
# firstly, read the issue in
my $issue = CIL::Issue->new_from_name($cil, $issue_name);
unless ( defined $issue ) {
fatal("Couldn't load issue '$issue_name'");
}
display_issue_full($cil, $issue);
}
sub cmd_status {
my ($cil, undef, $issue_name, $status) = @_;
unless ( defined $status ) {
fatal("provide a status to set this issue to");
}
# firstly, read the issue in
my $issue = CIL::Issue->new_from_name($cil, $issue_name);
unless ( defined $issue ) {
fatal("Couldn't load issue '$issue_name'");
}
# set the status for this issue
$issue->Status( $status );
$issue->save($cil);
display_issue($cil, $issue);
}
sub cmd_add {
my ($cil, undef, $issue_name) = @_;
# read in the new issue text
CIL::Utils->ensure_interactive();
my $fh = CIL::Utils->solicit( $new_issue_text );
my $issue = CIL::Issue->new_from_fh( 'tmp', $fh );
# we've got the issue, so let's name it
my $unique_str = $issue->Inserted . $issue->Summary . $issue->Description;
$issue->set_name( substr(md5_hex($unique_str), 0, 8) );
$issue->save($cil);
display_issue($cil, $issue);
}
sub cmd_edit {
my ($cil, undef, $issue_name) = @_;
my $issue = CIL::Issue->new_from_name($cil, $issue_name);
unless ( defined $issue ) {
fatal("Couldn't load issue '$issue_name'");
}
# create the ini file, then edit it
my $edit_issue_text = $issue->as_output;
# read in the new issue text
CIL::Utils->ensure_interactive();
my $fh = CIL::Utils->solicit( join('', @$edit_issue_text) );
my $issue_edited = CIL::Issue->new_from_fh( $issue->name, $fh );
unless ( defined $issue_edited ) {
fatal("couldn't create issue (program error)");
}
$issue_edited->save($cil);
display_issue($cil, $issue_edited);
}
sub cmd_comment {
my ($cil, undef, $issue_name) = @_;
my $issue = CIL::Issue->new_from_name($cil, $issue_name);
unless ( defined $issue ) {
fatal("couldn't load issue '$issue_name'");
}
# read in the new issue text
CIL::Utils->ensure_interactive();
my $fh = CIL::Utils->solicit( $add_comment_text );
my $comment = CIL::Comment->new_from_fh( 'tmp', $fh );
unless ( defined $comment ) {
fatal("could not create new comment");
}
# we've got the comment, so let's name it
my $unique_str = $comment->Inserted . $issue->Description;
$comment->set_name( substr(md5_hex($unique_str), 0, 8) );
# finally, tell it who it's parent is and then save
$comment->Issue( $issue->name );
$comment->save($cil);
# add the comment to the issue, update it's timestamp and save it out
$issue->add_comment( $comment );
$issue->save($cil);
display_issue_full($cil, $issue);
}
sub cmd_attach {
my ($cil, undef, $issue_name, $filename) = @_;
my $issue = CIL::Issue->new_from_name($cil, $issue_name);
unless ( defined $issue ) {
fatal("couldn't load issue '$issue_name'");
}
# check to see if the file exists
unless ( -r $filename ) {
fatal("couldn't read file '$filename'");
}
my $basename = basename( $filename );
my $add_attachment_text = <<"EOF";
Filename : $basename
CreatedBy : $gan <$gae>
File goes here ... this will be overwritten.
EOF
# read in the new issue text
CIL::Utils->ensure_interactive();
my $fh = CIL::Utils->solicit( $add_attachment_text );
my $attachment = CIL::Attachment->new_from_fh( 'tmp', $fh );
unless ( defined $attachment ) {
fatal("could not create new attachment");
}
# now add the file itself
my $contents = read_file( $filename );
$attachment->set_file_contents( $contents );
# set the size
my ($size) = (stat($filename))[7];
$attachment->Size( $size );
# we've got the attachment, so let's name it
my $unique_str = $attachment->Inserted . $attachment->File;
$attachment->set_name( substr(md5_hex($unique_str), 0, 8) );
# finally, tell it who it's parent is and then save
$attachment->Issue( $issue->name );
$attachment->save($cil);
# add the comment to the issue, update it's timestamp and save it out
$issue->add_attachment( $attachment );
$issue->save($cil);
display_issue_full($cil, $issue);
}
sub cmd_extract {
my ($cil, $args, $attachment_name) = @_;
my $attachment = CIL::Attachment->new_from_name($cil, $attachment_name);
unless ( defined $attachment ) {
fatal("Couldn't load attachment '$attachment_name'");
}
my $filename = $args->{f} || $attachment->Filename();
write_file( $filename, $attachment->as_binary );
}
## ----------------------------------------------------------------------------
sub check_paths {
my ($cil) = @_;
# make sure an issue directory is available
unless ( -d $cil->issue_dir ) {
fatal("couldn't find '" . $cil->issue_dir . "' directory");
}
}
## ----------------------------------------------------------------------------
# input/output
sub display_issue_summary {
my ($cil, $issue) = @_;
my $msg = $issue->name();
$msg .= "\t";
$msg .= $issue->Status();
$msg .= "\t";
$msg .= $issue->Summary();
msg($msg);
}
sub display_issue_headers {
my ($cil, $issue) = @_;
title( 'Issue ' . $issue->name() );
field( 'Summary', $issue->Summary() );
field( 'CreatedBy', $issue->CreatedBy() );
field( 'AssignedTo', $issue->AssignedTo() );
field( 'Inserted', $issue->Inserted() );
field( 'Status', $issue->Status() );
field( 'Labels', join(' ', @{$issue->Label()}) );
}
sub display_issue {
my ($cil, $issue) = @_;
separator();
title( 'Issue ' . $issue->name() );
field( 'Summary', $issue->Summary() );
field( 'Status', $issue->Status() );
field( 'CreatedBy', $issue->CreatedBy() );
field( 'AssignedTo', $issue->AssignedTo() );
field( 'Label', $_ )
foreach sort @{$issue->Label()};
field( 'Comment', $_ )
foreach sort @{$issue->Comment()};
field( 'Attachment', $_ )
foreach sort @{$issue->Attachment()};
field( 'Inserted', $issue->Inserted() );
field( 'Updated', $issue->Inserted() );
text('Description', $issue->Description());
separator();
}
sub display_issue_full {
my ($cil, $issue) = @_;
separator();
title( 'Issue ' . $issue->name() );
field( 'Summary', $issue->Summary() );
field( 'Status', $issue->Status() );
field( 'CreatedBy', $issue->CreatedBy() );
field( 'AssignedTo', $issue->AssignedTo() );
field( 'Label', $_ )
foreach sort @{$issue->Label()};
field( 'Inserted', $issue->Inserted() );
field( 'Updated', $issue->Inserted() );
text('Description', $issue->Description());
my $comments = $cil->get_comments_for( $issue );
foreach my $comment ( @$comments ) {
title( 'Comment ' . $comment->name() );
field( 'CreatedBy', $comment->CreatedBy() );
field( 'Inserted', $comment->Inserted() );
field( 'Updated', $comment->Inserted() );
text('Description', $comment->Description());
}
my $attachments = $cil->get_attachments_for( $issue );
foreach my $attachment ( @$attachments ) {
title( 'Attachment ' . $attachment->name() );
field( 'Filename', $attachment->Filename() );
field( 'CreatedBy', $attachment->CreatedBy() );
field( 'Inserted', $attachment->Inserted() );
field( 'Updated', $attachment->Inserted() );
msg();
}
separator();
}
## ----------------------------------------------------------------------------
# helper functions for this command line tool
sub get_options {
my ($in_opts, $booleans) = @_;
my $args = {};
Getopt::Mixed::init( @$in_opts );
while( my($opt, $val) = nextOption() ) {
# if boolean, keep a count of how many there is only
if ( exists $booleans->{$opt} ) {
$args->{$opt}++;
next;
}
# normal 'string' value
if ( defined $args->{$opt} ) {
unless ( ref $args->{$opt} eq 'ARRAY' ) {
$args->{$opt} = [ $args->{$opt} ];
}
push @{$args->{$opt}}, $val;
}
else {
$args->{$opt} = $val;
}
}
Getopt::Mixed::cleanup();
return $args;
}
sub msg {
print ( defined $_[0] ? $_[0] : '' );
print "\n";
}
sub separator {
msg('=' x 79);
}
sub title {
my ($title) = @_;
my $msg = "--- $title ";
$msg .= '-' x (74 - length($title));
msg($msg);
}
sub field {
my ($field, $value) = @_;
my $msg = "$field";
$msg .= " " x (12 - length($field));
msg("$msg: " . (defined $value ? $value : '') );
}
sub text {
my ($field, $value) = @_;
msg "";
msg($value);
msg "";
}
sub err {
print STDERR ( defined $_[0] ? $_[0] : '' );
print STDERR "\n";
}
sub fatal {
my ($msg) = @_;
chomp $msg;
print STDERR $msg, "\n";
exit 2;
}
## ----------------------------------------------------------------------------
# program info
sub usage {
print <<"END_USAGE";
Usage: $0 COMMAND [options]
Commands:
init [--path=PATH]
add
summary
list
show ISSUE
status ISSUE NEW_STATUS
edit ISSUE
comment ISSUE
attach ISSUE FILENAME
extract ATTACHMENT [--filename=FILENAME]
See for further information.
Report bugs to .
END_USAGE
}
## ----------------------------------------------------------------------------
=head1 NAME
cil - the command-line issue list
=head1 SYNOPSIS
$ cil init
$ cil summary
$ cil list
$ cil add
... added issue 'cafebabe' ...
$ cil show cafebabe
$ cil edit cafebabe
$ cil status cafebabe InProgress
$ cil comment cafebabe
... added comment 'deadbeef' ...
$ cil attach cafebabe filename.txt
... added attachment 'decaf7ea' ...
$ cil extract decaf7ea
$ cil extract decaf7ea --filename=other_filename.txt
=head1 DESCRIPTION
Cil is a small but useful command-line issue list. It saves issues, comments
and attachments as local files which you can check in to your repository.
=over
=item init [--path=PATH]
Creates a local '.cil' file and an 'issues' directory. If PATH is specified,
the config file and directory will be created in the destination directory.
=item summary
Displays a one line summary for each issue.
=item list
Shows each issue with more information.
=item add
Adds an issues after you have edited the input.
=item show ISSUE
Shows the issue name with more detail.
=item status ISSUE NEW_STATUS
Shortcut so that you can set a new status on an issue without having to edit
it.
=item edit ISSUE
Edits the issue. If it changes, set the updates time to now.
=item comment ISSUE
Adds a comment to an issues after you have edited the input.
=item attach ISSUE FILENAME
Adds that particular filename to an existing issue.
=item extract ATTACHMENT [--filename=FILENAME]
Extracts the file from the attachment number. If filename if given uses that,
otherwise it will use the original one saved along with the attachment.
=back
=head1 BUGS
Probably. Let me know :-)
=head1 TODO
There is a number of things to do. High on the list are:
* the ability to set Statuses from the command line
* set where you want your issues (from a .cil file)
* simple search first, proper search and indexing second
=head1 AUTHOR
Andrew Chilton
=head1 COPYRIGHT
Copyright (C) 2008 by Andrew Chilton
Cil is free software: you can redistribute it and/or modify it under the terms
of the GNU General Public License as published by the Free Software Foundation,
either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
this program. If not, see or write to the Free
Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
02110-1301, USA.
=cut
## ----------------------------------------------------------------------------