#!/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
## ----------------------------------------------------------------------------