#!/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 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 Email::Simple; use Email::Date qw(find_date); use CIL; use CIL::Issue; use CIL::Comment; use CIL::Attachment; ## ---------------------------------------------------------------------------- # constants my $y = 'y'; use constant VERSION => '0.4.2'; my @IN_OPTS = ( # strings 'p=s', # p = path 'path>p', # for 'add' 'f=s', # f = filename 'filename>f', # for 'extract' 's=s', # s = status 'status>s', # for 'summary', 'list' 'l=s', # l = label 'label>l', # for 'summary, 'list' 'c=s', # c = created-by 'created-by>c', # for 'summary', 'list' 'a=s', # a = assigned_to 'assigned-to>a',# for 'summary', 'list' # booleans 'is-open', # for 'summary', 'list' 'is-closed', # for 'summary', 'list' 'is-mine', # for 'summary', 'list' 'help', 'version', ); my %BOOLEAN_ARGS = ( 'help' => 1, 'version' => 1, 'is-open' => 1, 'is-closed' => 1, 'is-mine' => 1, ); ## ---------------------------------------------------------------------------- # 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; $command =~ s{-}{_}gxms; no strict 'refs'; if ( not defined &{"cmd_$command"} ) { Getopt::Mixed::abortMsg("'$command' is not a valid cil command."); } my $cil = CIL->new(); $cil->read_config_user(); $cil->read_config_file(); &{"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", <<'README'); This directory is used by CIL to track issues and feature requests. The home page for CIL is at http://kapiti.geek.nz/software/cil.html README } # $path/issues/ and $path/.cil create correctly msg("initialised empty issue list inside '$path/'"); } sub cmd_list { my ($cil, $args) = @_; check_paths($cil); # find all the issues my $issues = $cil->get_issues(); $issues = filter_issues( $cil, $issues, $args ); 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, $args) = @_; check_paths($cil); # find all the issues my $issues = $cil->get_issues(); $issues = filter_issues( $cil, $issues, $args ); 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 = load_issue_fuzzy( $cil, $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 = load_issue_fuzzy( $cil, $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) = @_; CIL::Utils->ensure_interactive(); my $user = user($cil); my $issue = CIL::Issue->new('tmpname'); $issue->Status('New'); $issue->CreatedBy( $user ); $issue->Description("Description ..."); add_issue_loop($cil, undef, $issue); } sub cmd_edit { my ($cil, undef, $issue_name) = @_; my $issue = load_issue_fuzzy( $cil, $issue_name ); CIL::Utils->ensure_interactive(); my $edit = $y; # keep going until we get a valid issue or we want to quit while ( $edit eq $y ) { # read in the new issue text my $fh = CIL::Utils->solicit( $issue->as_output ); $issue = CIL::Issue->new_from_fh( $issue->name, $fh ); # check if the issue is valid if ( $issue->is_valid($cil) ) { $edit = 'n'; } else { msg($_) foreach @{ $issue->errors }; $edit = ans('Would you like to re-edit (y/n): '); } } # if the issue is still invalid, they quit without correcting it return unless $issue->is_valid( $cil ); # save it $issue->save($cil); display_issue($cil, $issue); } sub cmd_comment { my ($cil, undef, $issue_name) = @_; my $issue = load_issue_fuzzy( $cil, $issue_name ); CIL::Utils->ensure_interactive(); # create the new comment my $comment = CIL::Comment->new('tmpname'); $comment->Issue( $issue->name ); $comment->CreatedBy( user($cil) ); $comment->Description("Description ..."); add_comment_loop($cil, undef, $issue, $comment); } sub cmd_attach { my ($cil, undef, $issue_name, $filename) = @_; my $issue = load_issue_fuzzy( $cil, $issue_name ); # check to see if the file exists unless ( -r $filename ) { fatal("couldn't read file '$filename'"); } my $basename = basename( $filename ); my $user = user($cil); my $add_attachment_text = <<"EOF"; Filename : $basename CreatedBy : $user 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 = time . $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 = load_attachment_fuzzy($cil, $attachment_name); my $filename = $args->{f} || $attachment->Filename(); write_file( $filename, $attachment->as_binary ); } sub cmd_fsck { my ($cil, $args) = @_; # this looks at all the issues it can find and checks for: # * validity # * all the comments are there # * all the attachments are there # then it checks each individual comment/attachment for: # * ToDo: validity # * it's parent exists check_paths($cil); # find all the issues, comments and attachments my $issues = $cil->get_issues(); my $issue = {}; foreach my $i ( @$issues ) { $issue->{$i->name} = $i; } my $comments = $cil->get_comments(); my $comment = {}; foreach my $c ( @$comments ) { $comment->{$c->name} = $c; } my $attachments = $cil->get_attachments(); my $attachment = {}; foreach my $a ( @$attachments ) { $attachment->{$a->name} = $a; } # ------ # issues my $errors = {}; if ( @$issues ) { foreach my $i ( sort { $a->Inserted cmp $b->Inserted } @$issues ) { my $name = $i->name; unless ( $i->is_valid($cil) ) { foreach ( @{ $i->errors } ) { push @{$errors->{$name}}, $_; } } # check that all it's comments are there and that they have this parent my $comments = $i->CommentList; foreach my $c ( @$comments ) { # see if this comment exists at all if ( exists $comment->{$c} ) { # check the parent is this issue push @{$errors->{$name}}, "comment '$c' is listed under issue '" . $i->name . "' but does not reciprocate" unless $comment->{$c}->Issue eq $i->name; } else { push @{$errors->{$name}}, "comment '$c' listed in issue '" . $i->name . "' does not exist"; } } # check that all it's attachments are there and that they have this parent my $attachments = $i->AttachmentList; foreach my $a ( @$attachments ) { # see if this attachment exists at all if ( exists $attachment->{$a} ) { # check the parent is this issue push @{$errors->{$name}}, "attachment '$a' is listed under issue '" . $i->name . "' but does not reciprocate" unless $attachment->{$a}->Issue eq $i->name; } else { push @{$errors->{$name}}, "attachment '$a' listed in issue '" . $i->name . "' does not exist"; } } # check that all it's 'DependsOn' are there and that they have this under 'Precedes' my $depends_on = $i->DependsOnList; foreach my $d ( @$depends_on ) { # see if this issue exists at all if ( exists $issue->{$d} ) { # check the 'Precedes' is this issue my %precedes = map { $_ => 1 } @{$issue->{$d}->PrecedesList}; push @{$errors->{$name}}, "issue '$d' should precede '" . $i->name . "' but doesn't" unless exists $precedes{$i->name}; } else { push @{$errors->{$name}}, "issue '$d' listed as a dependency of issue '" . $i->name . "' does not exist"; } } # check that all it's 'Precedes' are there and that they have this under 'DependsOn' my $precedes = $i->PrecedesList; foreach my $p ( @$precedes ) { # see if this issue exists at all if ( exists $issue->{$p} ) { # check the 'DependsOn' is this issue my %depends_on = map { $_ => 1 } @{$issue->{$p}->DependsOnList}; push @{$errors->{$name}}, "issue '$p' should depend on '" . $i->name . "' but doesn't" unless exists $depends_on{$i->name}; } else { push @{$errors->{$name}}, "issue '$p' listed as preceding issue '" . $i->name . "' does not exist"; } } } } print_fsck_errors('Issue', $errors); # -------- # comments $errors = {}; # loop through all the comments if ( @$comments ) { # check that their parent issues exist foreach my $c ( sort { $a->Inserted cmp $b->Inserted } @$comments ) { # check that the parent of each comment exists unless ( exists $issue->{$c->Issue} ) { push @{$errors->{$c->name}}, "comment '" . $c->name . "' refers to issue '" . $c->Issue . "' but issue does not exist"; } } } print_fsck_errors('Comment', $errors); # ----------- # attachments $errors = {}; # loop through all the attachments if ( @$attachments ) { # check that their parent issues exist foreach my $a ( sort { $a->Inserted cmp $b->Inserted } @$attachments ) { # check that the parent of each attachment exists unless ( exists $issue->{$a->Issue} ) { push @{$errors->{$a->name}}, "attachment '" . $a->name . "' refers to issue '" . $a->Issue . "' but issue does not exist"; } } } print_fsck_errors('Attachment', $errors); # ------------ # nothing left separator(); } sub cmd_am { my ($cil, $args, $email_filename) = @_; unless ( -f $email_filename ) { fatal("couldn't load email '$email_filename'"); } my $msg_text = read_file($email_filename); my $email = Email::Simple->new($msg_text); unless ( defined $email ) { fatal("email file '$email_filename' didn't look like an email"); } # extract some fields my $subject = $email->header('Subject'); my $from = $email->header('From'); my $date = find_date($email)->datetime; my $body = $email->body; # see if we can find any issue names in either the subject or the body my @issue_names; foreach my $text ( $subject, $body ) { my @new = ( $text =~ /\b\#?([0-9a-f]{8})\b/gxms ); push @issue_names, @new; } msg("Found possible issue names in email: ", ( join(' ', @issue_names) || '[none]' )); my %issue; foreach ( @issue_names ) { my $i = eval { CIL::Issue->new_from_name($cil, $_) }; next unless defined $i; $issue{$i->name} = $i; } if ( keys %issue ) { msg( "Found actual issues: " . (join(' ', keys %issue)) ); # create the new comment my $comment = CIL::Comment->new('tmpname'); $comment->Issue( '...' ); $comment->CreatedBy( $from ); $comment->Inserted( $date ); # $comment->Updated( $date ); $comment->Description( $body ); # display display_comment($cil, $comment); # found at least one issue, so this might be a comment my $issue; if ( keys %issue == 1 ) { $issue = (values %issue)[0]; } else { my $ans = ans('To which issue would you like to add this comment: '); # ToDo: decide whether we let them choose an arbitrary issue, for # now quit unless they choose one from the list return unless exists $issue{$ans}; # got a valid issue_name, so set the parent name $issue = $issue{$ans}; } # set the parent issue $comment->Issue( $issue->name ); add_comment_loop($cil, undef, $issue, $comment); } else { msg("Couldn't find reference to any issues in the email."); # no issue found so make up the issue first my $issue = CIL::Issue->new('tmpname'); $issue->Summary( $subject ); $issue->Status( 'New' ); $issue->CreatedBy( $from ); $issue->AssignedTo( user($cil) ); $issue->Inserted( $date ); $issue->Updated( $date ); $issue->Description( $body ); # display display_issue_full($cil, $issue); # then ask if the user would like to add it msg("Couldn't find any likely issues, so this might be a new one."); my $ans = ans('Would you like to add this message as an issue shown above (y/n): '); return unless $ans eq 'y'; add_issue_loop($cil, undef, $issue); } } sub cmd_depends_on { my ($cil, undef, $issue_name, $depends_name) = @_; my $issue = load_issue_fuzzy($cil, $issue_name); my $depends = load_issue_fuzzy($cil, $depends_name); $issue->add_depends_on( $depends->name ); $depends->add_precedes( $issue->name ); $issue->save($cil); $depends->save($cil); } sub cmd_precedes { my ($cil, undef, $issue_name, $depends_name) = @_; # switch them round and call 'DependsOn' cmd_depends_on($cil, undef, $depends_name, $issue_name); } ## ---------------------------------------------------------------------------- # helpers sub load_issue_fuzzy { my ($cil, $partial_name) = @_; my $issues = $cil->list_issues_fuzzy( $partial_name ); unless ( defined $issues ) { fatal("Couldn't find any issues using '$partial_name'"); } if ( @$issues > 1 ) { fatal('found multiple issues which match that name: ' . join(' ', map { $_->{name} } @$issues)); } my $issue_name = $issues->[0]->{name}; my $issue = CIL::Issue->new_from_name($cil, $issue_name); unless ( defined $issue ) { fatal("Couldn't load issue '$issue_name'"); } return $issue; } sub load_comment_fuzzy { my ($cil, $partial_name) = @_; my $comments = $cil->list_comments_fuzzy( $partial_name ); unless ( defined $comments ) { fatal("Couldn't find any comments using '$partial_name'"); } if ( @$comments > 1 ) { fatal('found multiple comments which match that name: ' . join(' ', map { $_->{name} } @$comments)); } my $comment_name = $comments->[0]->{name}; my $comment = CIL::comment->new_from_name($cil, $comment_name); unless ( defined $comment ) { fatal("Couldn't load comment '$comment_name'"); } return $comment; } sub load_attachment_fuzzy { my ($cil, $partial_name) = @_; my $attachments = $cil->list_attachments_fuzzy( $partial_name ); unless ( defined $attachments ) { fatal("Couldn't find any attachments using '$partial_name'"); } if ( @$attachments > 1 ) { fatal('found multiple attachments which match that name: ' . join(' ', map { $_->{name} } @$attachments)); } my $attachment_name = $attachments->[0]->{name}; my $attachment = CIL::Attachment->new_from_name($cil, $attachment_name); unless ( defined $attachment ) { fatal("Couldn't load attachment '$partial_name'"); } return $attachment; } sub ans { my ($msg) = @_; print $msg; my $ans = ; chomp $ans; print "\n"; return $ans; } sub add_issue_loop { my ($cil, undef, $issue) = @_; my $edit = $y; # keep going until we get a valid issue or we want to quit while ( $edit eq $y ) { # read in the new issue text my $fh = CIL::Utils->solicit( $issue->as_output ); $issue = CIL::Issue->new_from_fh( 'tmp', $fh ); # check if the issue is valid if ( $issue->is_valid($cil) ) { $edit = 'n'; } else { msg($_) foreach @{ $issue->errors }; $edit = ans('Would you like to re-edit (y/n): '); } } # if the issue is still invalid, they quit without correcting it return unless $issue->is_valid( $cil ); # we've got the issue, so let's name it my $unique_str = time . $issue->Inserted . $issue->Summary . $issue->Description; $issue->set_name( substr(md5_hex($unique_str), 0, 8) ); $issue->save($cil); display_issue($cil, $issue); return $issue; } sub add_comment_loop { my ($cil, undef, $issue, $comment) = @_; my $edit = $y; # keep going until we get a valid issue or we want to quit while ( $edit eq $y ) { # read in the new comment text my $fh = CIL::Utils->solicit( $comment->as_output ); $comment = CIL::Comment->new_from_fh( 'tmp', $fh ); # check if the comment is valid if ( $comment->is_valid($cil) ) { $edit = 'n'; } else { msg($_) foreach @{ $issue->errors }; $edit = ans('Would you like to re-edit (y/n): '); } } # if the comment is still invalid, they quit without correcting it return unless $comment->is_valid( $cil ); # we've got the comment, so let's name it my $unique_str = time . $comment->Inserted . $issue->Description; $comment->set_name( substr(md5_hex($unique_str), 0, 8) ); # finally, save it $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); return $comment; } sub check_paths { my ($cil) = @_; # make sure an issue directory is available unless ( -d $cil->IssueDir ) { fatal("couldn't find '" . $cil->IssueDir . "' directory"); } } sub filter_issues { my ($cil, $issues, $args) = @_; # don't filter if we haven't been given anything return $issues unless %$args; # check that they aren't filtering on both --assigned-to and --is-mine if ( defined $args->{a} and defined $args->{'is-mine'} ) { fatal("the --assigned-to and --is-mine filters are mutually exclusive"); } # take a copy of the whole lot first (so we don't destroy the input list) my @new_issues = @$issues; # firstly, get out the Statuses we want if ( defined $args->{s} ) { @new_issues = grep { $_->Status eq $args->{s} } @new_issues; } # then see if we want a particular label (could be a bit nicer) if ( defined $args->{l} ) { my @tmp; foreach my $issue ( @new_issues ) { push @tmp, $issue if grep { $_ eq $args->{l} } @{$issue->LabelList}; } @new_issues = @tmp; } # filter out dependent on open/closed if ( defined $args->{'is-open'} ) { # just get the open issues @new_issues = grep { $_->is_open($cil) } @new_issues; } if ( defined $args->{'is-closed'} ) { # just get the closed issues @new_issues = grep { $_->is_closed($cil) } @new_issues; } # filter out 'created by' if ( defined $args->{c} ) { @new_issues = grep { $args->{c} eq $_->created_by_email } @new_issues; } # filter out 'assigned to' $args->{a} = $cil->UserEmail if defined $args->{'is-mine'}; if ( defined $args->{a} ) { @new_issues = grep { $args->{a} eq $_->assigned_to_email } @new_issues; } return \@new_issues; } sub print_fsck_errors { my ($entity, $errors) = @_; return unless keys %$errors; separator(); foreach my $issue_name ( keys %$errors ) { title( "$entity $issue_name "); foreach my $error ( @{$errors->{$issue_name}} ) { msg("* $error"); } } } ## ---------------------------------------------------------------------------- # 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->LabelList()}) ); field( 'DependsOn', join(' ', @{$issue->DependsOnList()}) ); field( 'Precedes', join(' ', @{$issue->PrecedesList()}) ); } 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->LabelList()}; field( 'Comment', $_ ) foreach sort @{$issue->CommentList()}; field( 'Attachment', $_ ) foreach sort @{$issue->AttachmentList()}; field( 'DependsOn', $_ ) foreach sort @{$issue->DependsOnList()}; field( 'Precedes', $_ ) foreach sort @{$issue->PrecedesList()}; 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( 'DependsOn', $_ ) foreach sort @{$issue->DependsOnList()}; field( 'Precedes', $_ ) foreach sort @{$issue->PrecedesList()}; field( 'Inserted', $issue->Inserted() ); field( 'Updated', $issue->Inserted() ); text('Description', $issue->Description()); my $comments = $cil->get_comments_for( $issue ); foreach my $comment ( @$comments ) { display_comment( $cil, $comment ); } my $attachments = $cil->get_attachments_for( $issue ); foreach my $attachment ( @$attachments ) { display_attachment( $cil, $attachment ); msg(); } separator(); } sub display_comment { my ($cil, $comment) = @_; title( 'Comment ' . $comment->name() ); field( 'CreatedBy', $comment->CreatedBy() ); field( 'Inserted', $comment->Inserted() ); field( 'Updated', $comment->Inserted() ); text('Description', $comment->Description()); } sub display_attachment { my ($cil, $attachment) = @_; title( 'Attachment ' . $attachment->name() ); field( 'Filename', $attachment->Filename() ); field( 'CreatedBy', $attachment->CreatedBy() ); field( 'Inserted', $attachment->Inserted() ); field( 'Updated', $attachment->Inserted() ); } sub user { my ($cil) = @_; return $cil->UserName . ' <' . $cil->UserEmail . '>'; } ## ---------------------------------------------------------------------------- # 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 [FILTERS...] list [FILTERS...] show ISSUE status ISSUE NEW_STATUS edit ISSUE comment ISSUE attach ISSUE FILENAME extract ATTACHMENT [--filename=FILENAME] fsck Filters: --status=? --is-open --is-closed --label=? --assigned-to=? --is-mine 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 list --status=New $ cil list --label=Release-v0.1 $ cil list --is-open $ 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 $ cil am email.txt $ cil fsck =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 [filters] Displays a one line summary for each issue. You may filter on both the Status and Label fields. =item list [filters] Shows each issue with more information. You may filter on both the Status and Label fields. =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 depends-on ISSUE1 ISSUE2 Shortcut so that cil will add a 'DependsOn' from issue 1 to issue 2. Conversley, issue 2 will also then contain a 'Precedes' pointer to issue 1. =item precedes ISSUE1 ISSUE2 This is the exact opposite of C and is here for convenience and completeness. ie. issue 1 has to be completed before issue 2. =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. =item fsck Tries to help you organise your issues if any aren't valid or have broken relationships. =item am Applies an email message to the issue list. It tries to figure out the type of email it is, whether it is a new issue or a comment on an already existing issue. For example, if it can find valid issue names in the subject or body of the message, it adds it as a comment to that issue. If it can't find any valid issue names, it presumes it's a new issue and adds that. Note: this command will deal with Mailbox format files later on. =back =head1 FILTERS Filters can be used on both the C and C commands. Most can be combined. See each individual filter for details. =over =item --status=STATUS You can choose any of the Statuses which might appear in your issues. This status does not have to be defined in your C<.cil> file, even if you have C turned on. =item --label=LABEL You can choose any of the Labels which might appear in your issues. This label does not have to be defined in your C<.cil> file, even if you have C turned on. =item --is-open, --is-closed These check both C and C from your C<.cil> file. If both are specified, you're likely to get no issues unless you explicitely defined a status as being in both lists (for whatever reason you have). =item --assigned-to=EMAIL_ADDRESS, --is-mine These items are mutually exclusive. The C<--assigned-to> just checks the email address in the AssignedTo field. It does not match anything else in that field, including any preceding name or any angle brackets. The C<--is-mine> filter is a shortcut to asking if AssignedTo is you. Cil knows your email address if you define it in your user's C<~/.cilrc> file as C. =back =head1 .cil The C<.cil> file is used to configure bits and pieces within cil for this particular issue list. The following options are available and where stated, may be declared multiple times: The C<.cil> file is fairly simple and an example can be seen here: StatusStrict: 1 StatusAllowedList: New StatusAllowedList: InProgress StatusAllowedList: Finished StatusOpenList: New StatusOpenList: InProgress StatusClosedList: Finished LabelStrict: 1 LabelAllowedList: Type-Enhancement LabelAllowedList: Type-Defect LabelAllowedList: Priority-High LabelAllowedList: Priority-Medium LabelAllowedList: Priority-Low =over =item StatusStrict Default: 0, Type: Boolean (0/1) If this is set to a true value then cil checks that the status you enter into an issue (after adding or editing) is also in the allowed list (see StatusAllowedList). =item StatusAllowedList Default: empty, Type: List This list is checked against when adding or editing issues but only if you have StatusStrict on. =item StatusOpenList Default: empty, Type: List This list is checked against when filtering with --is-open. =item StatusClosedList Default: empty, Type: List This list is checked against when filtering with --is-closed. =item LabelStrict Default: 0, Type: Boolean (0/1) This determines that labels you enter are checked against LabelAllowedList. Set to 1 if you require this feature. =item LabelAllowedList Default: empty, Type: List This determines which labels are allowed if you have turned on LabelStrict. =back =head1 ~/.cilrc The C<~/.cilrc> file is read to configure the user's preferences for all cil lists they're using. It is of the same format as the C<.cil> file and contains the following options: UserName: Andrew Chilton UserEmail: andychilton@gmail.com =over =item UserName Default: 'Name', Type: String This is used as a default in the C and C fields in any issues/comments/attachments you add. =item UserEmail Default: 'Email', Type: String This is used as a default in the C and C fields in any issues/comments/attachments you add. =back =head1 BUGS Probably. Let me know :-) =head1 TODO To get a ToDo list for cil, clone the repo, find the issues/ dir and type: $ cil --is-open This gives the current outstanding issues in cil. =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 ## ----------------------------------------------------------------------------