#!/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, undef, $attachment_name, $args) = @_; 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 ## ----------------------------------------------------------------------------