From 46e9fa3874fd1abf574ef021211849684b987521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Cohen=20Arazi?= Date: Fri, 3 Oct 2025 00:13:06 -0300 Subject: [PATCH] GitBz::Bug introduced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Cohen Arazi --- bin/git-bz | 1 + lib/GitBz/Bug.pm | 70 ++++++++++++++++ lib/GitBz/Commands/Attach.pm | 158 +++++++++++++++++++++++++++++------ lib/GitBz/Commands/Edit.pm | 140 +++++++++++++++++++------------ lib/GitBz/Git.pm | 7 +- lib/GitBz/RestClient.pm | 19 ++--- 6 files changed, 303 insertions(+), 92 deletions(-) create mode 100644 lib/GitBz/Bug.pm diff --git a/bin/git-bz b/bin/git-bz index 2564ce4..8904658 100755 --- a/bin/git-bz +++ b/bin/git-bz @@ -16,6 +16,7 @@ # along with git-bz; if not, see . use Modern::Perl; + use FindBin; use lib "$FindBin::RealBin/../lib"; use GitBz::Commands; diff --git a/lib/GitBz/Bug.pm b/lib/GitBz/Bug.pm new file mode 100644 index 0000000..961ad8b --- /dev/null +++ b/lib/GitBz/Bug.pm @@ -0,0 +1,70 @@ +package GitBz::Bug; + +# This file is part of git-bz. +# +# git-bz 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. +# +# git-bz 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 git-bz; if not, see . + +use Modern::Perl; + +use GitBz::Exception; + +sub get { + my ( $class, $client, $number ) = @_; + + my $bug_data = $client->get_bug($number); + GitBz::Exception->throw("Bug $number not found") unless $bug_data; + + return bless { + client => $client, + data => $bug_data, + _attachments => undef, + }, $class; +} + +sub id { $_[0]->{data}->{id} } +sub summary { $_[0]->{data}->{summary} } +sub status { $_[0]->{data}->{status} } +sub resolution { $_[0]->{data}->{resolution} } +sub depends_on { $_[0]->{data}->{depends_on} || [] } +sub cf_patch_complexity { $_[0]->{data}->{cf_patch_complexity} } + +sub attachments { + my ($self) = @_; + + unless ( $self->{_attachments} ) { + $self->{_attachments} = $self->{client}->get_attachments( $self->id ); + } + + return $self->{_attachments}; +} + +sub update { + my ( $self, %params ) = @_; + + return $self->{client}->update_bug( $self->id, %params ); +} + +sub add_attachment { + my ( $self, $data, $filename, $summary, %opts ) = @_; + + return $self->{client}->add_attachment( $self->id, $data, $filename, $summary, %opts ); +} + +sub obsolete_attachment { + my ( $self, $attachment_id ) = @_; + + return $self->{client}->obsolete_attachment($attachment_id); +} + +1; \ No newline at end of file diff --git a/lib/GitBz/Commands/Attach.pm b/lib/GitBz/Commands/Attach.pm index 60f7d2f..271e20d 100644 --- a/lib/GitBz/Commands/Attach.pm +++ b/lib/GitBz/Commands/Attach.pm @@ -17,8 +17,6 @@ package GitBz::Commands::Attach; use Modern::Perl; -use utf8; - use Getopt::Long qw(GetOptionsFromArray); use Try::Tiny qw(catch try); use File::Temp; @@ -26,10 +24,14 @@ use File::Temp; use GitBz::Git; use GitBz::Exception; use GitBz::StatusWorkflow; +use GitBz::Bug; sub new { my ( $class, $commands ) = @_; - bless { commands => $commands }, $class; + bless { + commands => $commands, + client => $commands->{client} + }, $class; } sub execute { @@ -50,7 +52,7 @@ sub execute { $self->attach_patches( $bug_ref, \@commits, \%opts ); - print "Successfully attached " . scalar(@commits) . " patch(es) to bug $bug_ref\n"; + print "\n✓ Successfully attached " . scalar(@commits) . " patch(es) to bug $bug_ref\n"; } catch { GitBz::Exception->throw("Attach failed: $_"); }; @@ -90,10 +92,13 @@ sub extract_bug_ref { sub attach_patches { my ( $self, $bug_ref, $commits, $opts ) = @_; - my $client = $self->{commands}->{client}; - my $bug = $client->get_bug($bug_ref); + my $client = $self->{client}; + my $bug = GitBz::Bug->get( $client, $bug_ref ); my $is_first = 1; + my %bug_updates; + my @obsoletes_list; + for my $commit (@$commits) { my $patch = GitBz::Git->format_patch( $commit->{id} . '^..' . $commit->{id} ); my $filename = sprintf( "%s.patch", substr( $commit->{id}, 0, 7 ) ); @@ -103,29 +108,86 @@ sub attach_patches { my @obsoletes; if ( $opts->{edit} && $is_first ) { - ( $description, $comment, my $obsoletes_ref ) = $self->edit_attachment_comment( $bug, $commit ); - @obsoletes = @$obsoletes_ref if $obsoletes_ref; + ( $description, $comment, my $obsoletes_ref, my $updates_ref ) = + $self->edit_attachment_comment( $bug, $commit ); + @obsoletes = @$obsoletes_ref if $obsoletes_ref; + %bug_updates = %$updates_ref if $updates_ref; + push @obsoletes_list, @obsoletes; } - $client->add_attachment( - $bug_ref, + $bug->add_attachment( $patch, $filename, $description, - comment => $comment, - obsoletes => \@obsoletes + comment => $comment, ); - print "Attached: $description\n"; + # Store attachment info for later display + push @{ $self->{_attached} }, $description; $is_first = 0; } + + # Show all updates together + if ( %bug_updates || @obsoletes_list || $self->{_attached} ) { + print "\nUpdating bug $bug_ref:\n"; + + # Show attachments + for my $desc ( @{ $self->{_attached} || [] } ) { + print " ✓ Attached: $desc\n"; + } + + # Show bug field updates + if ( $bug_updates{status} && $bug_updates{status} ne $bug->status ) { + print " ✓ Status: " . $bug->status . " → $bug_updates{status}\n"; + } + if ( $bug_updates{cf_patch_complexity} + && $bug_updates{cf_patch_complexity} ne ( $bug->cf_patch_complexity || '' ) ) + { + my $old = $bug->cf_patch_complexity || 'none'; + print " ✓ Patch-complexity: $old → $bug_updates{cf_patch_complexity}\n"; + } + if ( $bug_updates{comment} ) { + print " ✓ Added comment\n"; + } + if ( $bug_updates{depends_on} ) { + my $depends_change = $bug_updates{depends_on}; + if ( $depends_change->{add} ) { + print " ✓ Depends: added " . join( ' ', @{ $depends_change->{add} } ) . "\n"; + } + if ( $depends_change->{remove} ) { + print " ✓ Depends: removed " . join( ' ', @{ $depends_change->{remove} } ) . "\n"; + } + } + + # Show obsoleted attachments + for my $attach_id (@obsoletes_list) { + my $attachments = $bug->attachments; + my %attach_lookup = map { $_->{id} => $_->{summary} } @$attachments; + my $summary = $attach_lookup{$attach_id} || "Unknown"; + print " ✓ Obsoleted attachment $attach_id - $summary\n"; + } + + # Perform updates + if (%bug_updates) { + $bug->update(%bug_updates); + } + + # Perform obsoletes + for my $attach_id (@obsoletes_list) { + $bug->obsolete_attachment($attach_id); + } + } + + # Obsoletes are now handled in the unified update section above } sub edit_attachment_comment { my ( $self, $bug, $commit ) = @_; + my $client = $self->{client}; + my $template = ""; - $template .= "# Attachment to Bug $bug->{id} - $bug->{summary}\n\n"; + $template .= "# Attachment to Bug " . $bug->id . " - " . $bug->summary . "\n\n"; $template .= $commit->{subject} . "\n\n"; # Add commit body as initial comment @@ -133,9 +195,10 @@ sub edit_attachment_comment { $template .= $body . "\n\n" if $body; # Show existing patches for obsoleting - if ( $bug->{attachments} && @{ $bug->{attachments} } ) { - for my $patch ( @{ $bug->{attachments} } ) { - next unless $patch->{is_patch}; + my $attachments = $bug->attachments; + if ( $attachments && @$attachments ) { + for my $patch (@$attachments) { + next unless $patch->{is_patch} && !$patch->{is_obsolete}; my $obsoleted = ( $commit->{subject} eq $patch->{summary} ) ? "" : "#"; $template .= "${obsoleted}Obsoletes: $patch->{id} - $patch->{summary}\n"; } @@ -143,17 +206,16 @@ sub edit_attachment_comment { } # Add status options - my $client = $self->{commands}->{client}; my $workflow = GitBz::StatusWorkflow->new($client); - $template .= "# Current status: $bug->{status}\n"; - my $status_values = $workflow->get_next_status_values( $bug->{status} ); + $template .= "# Current status: " . $bug->status . "\n"; + my $status_values = $workflow->get_next_status_values( $bug->status ); for my $status (@$status_values) { $template .= "# Status: $status\n"; } $template .= "\n"; # Add patch complexity options - my $complexity = $bug->{cf_patch_complexity} || ""; + my $complexity = $bug->cf_patch_complexity || ""; $template .= "# Current patch-complexity: $complexity\n"; my $complexity_values = $client->get_field_values('cf_patch_complexity'); if ($complexity_values) { @@ -164,9 +226,16 @@ sub edit_attachment_comment { $template .= "\n"; # Add depends options - my $depends = $bug->{depends_on} || []; - my $depends_str = ref($depends) eq 'ARRAY' ? join( ' ', @$depends ) : $depends; + my $depends_list = $bug->depends_on; + my $depends_str = @$depends_list ? join( ' ', @$depends_list ) : ''; $template .= "# Current depends: $depends_str\n"; + + # Show current depends uncommented + for my $dep (@$depends_list) { + $template .= "Depends: bug $dep\n"; + } + + # Show skeleton commented $template .= "# Depends: bug xxxx\n"; $template .= "# Depends: bug yyyy\n"; $template .= "\n"; @@ -176,7 +245,7 @@ sub edit_attachment_comment { $template .= "# To obsolete existing patches, uncomment the appropriate lines.\n"; my $edited = $self->edit_template($template); - return $self->parse_edited_content($edited); + return $self->parse_edited_content( $edited, $bug ); } sub edit_template { @@ -198,20 +267,27 @@ sub edit_template { } sub parse_edited_content { - my ( $self, $content ) = @_; + my ( $self, $content, $bug ) = @_; my @lines = split /\n/, $content; - my @non_comment_lines = grep { !/^#/ && /\S/ } @lines; # Also filter empty lines + my @non_comment_lines = grep { !/^#/ && /\S/ } @lines; my $description = shift @non_comment_lines || ""; $description =~ s/^\s+|\s+$//g; my @obsoletes; my @comment_lines; + my %bug_updates; for my $line (@non_comment_lines) { if ( $line =~ /^\s*Obsoletes\s*:\s*(\d+)/ ) { push @obsoletes, $1; + } elsif ( $line =~ /^\s*Status\s*:\s*(.+)/ ) { + $bug_updates{status} = $1; + } elsif ( $line =~ /^\s*Patch-complexity\s*:\s*(.+)/ ) { + $bug_updates{cf_patch_complexity} = $1; + } elsif ( $line =~ /^\s*Depends\s*:\s*([Bb][Uu][Gg])?\s*(\d+)/ ) { + push @{ $bug_updates{depends_on} }, $2; } else { push @comment_lines, $line; } @@ -220,9 +296,37 @@ sub parse_edited_content { my $comment = join( "\n", @comment_lines ); $comment =~ s/^\s+|\s+$//g; + if ($comment) { + $bug_updates{comment} = { body => $comment }; + } + + # Convert depends_on to proper API format with add/remove actions + if ( $bug_updates{depends_on} ) { + my @new_depends = @{ $bug_updates{depends_on} }; + my @old_depends = @{ $bug->depends_on }; + + my @to_add = grep { + my $new = $_; + !grep { $_ eq $new } @old_depends + } @new_depends; + my @to_remove = grep { + my $old = $_; + !grep { $_ eq $old } @new_depends + } @old_depends; + + if ( @to_add || @to_remove ) { + my %depends_update; + $depends_update{add} = \@to_add if @to_add; + $depends_update{remove} = \@to_remove if @to_remove; + $bug_updates{depends_on} = \%depends_update; + } else { + delete $bug_updates{depends_on}; + } + } + GitBz::Exception->throw("Empty description, aborting\n") unless $description; - return ( $description, $comment, \@obsoletes ); + return ( $description, $comment, \@obsoletes, \%bug_updates ); } 1; diff --git a/lib/GitBz/Commands/Edit.pm b/lib/GitBz/Commands/Edit.pm index 3d1031a..58aac86 100644 --- a/lib/GitBz/Commands/Edit.pm +++ b/lib/GitBz/Commands/Edit.pm @@ -17,14 +17,13 @@ package GitBz::Commands::Edit; use Modern::Perl; -use utf8; - use Getopt::Long qw(GetOptionsFromArray); use Try::Tiny qw(catch try); use File::Temp; use GitBz::Git; use GitBz::Exception; use GitBz::StatusWorkflow; +use GitBz::Bug; sub new { my ( $class, $commands ) = @_; @@ -76,8 +75,7 @@ sub edit_bug { my ( $self, $bug_ref, $opts ) = @_; my $client = $self->{commands}->{client}; - my $bug = $client->get_bug($bug_ref); - GitBz::Exception->throw("Bug $bug_ref not found") unless $bug; + my $bug = GitBz::Bug->get( $client, $bug_ref ); my $template = $self->create_bug_template( $bug, $opts ); my $edited = $self->edit_template($template); @@ -106,7 +104,7 @@ sub edit_commits { my $changed = $self->edit_bug_with_commits( $bug_ref, $bugs{$bug_ref}, $opts ); $any_changed ||= $changed; } - + return $any_changed; } @@ -114,8 +112,7 @@ sub edit_bug_with_commits { my ( $self, $bug_ref, $commits, $opts ) = @_; my $client = $self->{commands}->{client}; - my $bug = $client->get_bug($bug_ref); - GitBz::Exception->throw("Bug $bug_ref not found") unless $bug; + my $bug = GitBz::Bug->get( $client, $bug_ref ); my $template = $self->create_bug_template_with_commits( $bug, $commits, $opts ); my $edited = $self->edit_template($template); @@ -128,29 +125,29 @@ sub create_bug_template { my $client = $self->{commands}->{client}; my $template = ""; - $template .= "# Bug $bug->{id} - $bug->{summary}\n\n"; - + $template .= "# Bug " . $bug->id . " - " . $bug->summary . "\n\n"; + # Show existing patches for obsoleting - my $attachments = $client->get_attachments($bug->{id}); + my $attachments = $bug->attachments; if ( $attachments && @$attachments ) { - for my $patch ( @$attachments ) { + for my $patch (@$attachments) { next unless $patch->{is_patch} && !$patch->{is_obsolete}; $template .= "#Obsoletes: $patch->{id} - $patch->{summary}\n"; } $template .= "\n"; } - + # Add status options my $workflow = GitBz::StatusWorkflow->new($client); - $template .= "# Current status: $bug->{status}\n"; - my $status_values = $workflow->get_next_status_values($bug->{status}); + $template .= "# Current status: " . $bug->status . "\n"; + my $status_values = $workflow->get_next_status_values( $bug->status ); for my $status (@$status_values) { $template .= "# Status: $status\n"; } $template .= "\n"; - + # Add patch complexity options - my $complexity = $bug->{cf_patch_complexity} || ""; + my $complexity = $bug->cf_patch_complexity || ""; $template .= "# Current patch-complexity: $complexity\n"; my $complexity_values = $client->get_field_values('cf_patch_complexity'); if ($complexity_values) { @@ -159,15 +156,22 @@ sub create_bug_template { } } $template .= "\n"; - + # Add depends options - my $depends = $bug->{depends_on} || []; - my $depends_str = ref($depends) eq 'ARRAY' ? join(' ', @$depends) : $depends; + my $depends_list = $bug->depends_on; + my $depends_str = @$depends_list ? join( ' ', @$depends_list ) : ''; $template .= "# Current depends: $depends_str\n"; + + # Show current depends uncommented + for my $dep (@$depends_list) { + $template .= "Depends: bug $dep\n"; + } + + # Show skeleton commented $template .= "# Depends: bug xxxx\n"; $template .= "# Depends: bug yyyy\n"; $template .= "\n"; - + $template .= "# Enter comment below. Lines starting with '#' will be ignored.\n"; $template .= "# To obsolete patches, uncomment the appropriate Obsoletes lines.\n"; @@ -217,14 +221,14 @@ sub update_bug { my @lines = split /\n/, $edited; my @non_comment_lines = grep { !/^#/ && /\S/ } @lines; - + # Early return if no non-comment lines - no API calls needed return 0 unless @non_comment_lines; - + my @obsoletes; my @comment_lines; my %update_params; - + for my $line (@non_comment_lines) { if ( $line =~ /^\s*Status\s*:\s*(.+)/ ) { $update_params{status} = $1; @@ -240,71 +244,99 @@ sub update_bug { push @comment_lines, $line; } } - + my $comment = join( "\n", @comment_lines ); $comment =~ s/^\s+|\s+$//g; - + # Early return if no changes return 0 unless %update_params || $comment || @obsoletes; - + # Only fetch bug data if we have changes to process - my $bug = $client->get_bug($bug_ref); + my $bug = GitBz::Bug->get( $client, $bug_ref ); my $changed = 0; - + # Check if there are actual changes before making API call my %actual_changes; - if ($update_params{status} && $update_params{status} ne $bug->{status}) { + if ( $update_params{status} && $update_params{status} ne $bug->status ) { $actual_changes{status} = $update_params{status}; } - if ($update_params{resolution} && $update_params{resolution} ne ($bug->{resolution} || '')) { + if ( $update_params{resolution} && $update_params{resolution} ne ( $bug->resolution || '' ) ) { $actual_changes{resolution} = $update_params{resolution}; } - if ($update_params{cf_patch_complexity} && $update_params{cf_patch_complexity} ne ($bug->{cf_patch_complexity} || '')) { + if ( $update_params{cf_patch_complexity} + && $update_params{cf_patch_complexity} ne ( $bug->cf_patch_complexity || '' ) ) + { $actual_changes{cf_patch_complexity} = $update_params{cf_patch_complexity}; } - if ($update_params{depends_on}) { - $actual_changes{depends_on} = $update_params{depends_on}; + if ( $update_params{depends_on} ) { + my @new_depends = @{ $update_params{depends_on} }; + my @old_depends = @{ $bug->depends_on }; + + my @to_add = grep { + my $new = $_; + !grep { $_ eq $new } @old_depends + } @new_depends; + my @to_remove = grep { + my $old = $_; + !grep { $_ eq $old } @new_depends + } @old_depends; + + if ( @to_add || @to_remove ) { + my %depends_update; + $depends_update{add} = \@to_add if @to_add; + $depends_update{remove} = \@to_remove if @to_remove; + $actual_changes{depends_on} = \%depends_update; + } } if ($comment) { $actual_changes{comment} = { body => $comment }; } - + if (%actual_changes) { - print "Updating bug $bug_ref\n"; - + print "Updating bug $bug_ref:\n"; + # Show field changes - if ($actual_changes{status}) { - print "Status changed: $bug->{status} → $actual_changes{status}\n"; + if ( $actual_changes{status} ) { + print " ✓ Status: " . $bug->status . " → $actual_changes{status}\n"; + } + if ( $actual_changes{resolution} ) { + my $old = $bug->resolution || 'none'; + print " ✓ Resolution: $old → $actual_changes{resolution}\n"; } - if ($actual_changes{resolution}) { - my $old = $bug->{resolution} || 'none'; - print "Resolution changed: $old → $actual_changes{resolution}\n"; + if ( $actual_changes{cf_patch_complexity} ) { + my $old = $bug->cf_patch_complexity || 'none'; + print " ✓ Patch-complexity: $old → $actual_changes{cf_patch_complexity}\n"; } - if ($actual_changes{cf_patch_complexity}) { - my $old = $bug->{cf_patch_complexity} || 'none'; - print "Patch-complexity changed: $old → $actual_changes{cf_patch_complexity}\n"; + if ( $actual_changes{comment} ) { + print " ✓ Added comment\n"; } - if ($actual_changes{comment}) { - print "Added comment\n"; + if ( $actual_changes{depends_on} ) { + my $depends_change = $actual_changes{depends_on}; + if ( $depends_change->{add} ) { + print " ✓ Depends: added " . join( ' ', @{ $depends_change->{add} } ) . "\n"; + } + if ( $depends_change->{remove} ) { + print " ✓ Depends: removed " . join( ' ', @{ $depends_change->{remove} } ) . "\n"; + } } - - $client->update_bug( $bug_ref, %actual_changes ); + + $bug->update(%actual_changes); $changed = 1; } - + # Handle obsoleting attachments if (@obsoletes) { - my $attachments = $client->get_attachments($bug_ref); + my $attachments = $bug->attachments; my %attach_lookup = map { $_->{id} => $_->{summary} } @$attachments; - + for my $attach_id (@obsoletes) { - $client->obsolete_attachment($attach_id); + $bug->obsolete_attachment($attach_id); my $summary = $attach_lookup{$attach_id} || "Unknown"; - print "Obsoleted attachment $attach_id - $summary\n"; + print " ✓ Obsoleted attachment $attach_id - $summary\n"; $changed = 1; } } - + return $changed; } diff --git a/lib/GitBz/Git.pm b/lib/GitBz/Git.pm index ce86e52..c0c8887 100644 --- a/lib/GitBz/Git.pm +++ b/lib/GitBz/Git.pm @@ -16,6 +16,8 @@ package GitBz::Git; # along with git-bz; if not, see . use Modern::Perl; + +use Encode qw(decode); use IPC::Run3; use Try::Tiny qw(catch try); use GitBz::Exception; @@ -31,7 +33,10 @@ sub run { if ( $? != 0 ) { GitBz::Exception::Git->throw("Git command failed: $stderr"); } - chomp $stdout if $stdout; + if ($stdout) { + $stdout = decode( 'UTF-8', $stdout, Encode::FB_CROAK | Encode::LEAVE_SRC ); + chomp $stdout; + } return $stdout || ''; } catch { GitBz::Exception::Git->throw($_); diff --git a/lib/GitBz/RestClient.pm b/lib/GitBz/RestClient.pm index 537dc46..0d5511b 100644 --- a/lib/GitBz/RestClient.pm +++ b/lib/GitBz/RestClient.pm @@ -16,7 +16,6 @@ package GitBz::RestClient; # along with git-bz; if not, see . use Modern::Perl; - use LWP::UserAgent; use JSON; use MIME::Base64; @@ -71,14 +70,14 @@ sub login { sub get_token { my ($self) = @_; - + return $self->{token} if $self->{token}; - - if ($self->{username} && $self->{password}) { + + if ( $self->{username} && $self->{password} ) { $self->login(); return $self->{token}; } - + return undef; } @@ -170,7 +169,7 @@ sub update_bug { sub get_field_values { my ( $self, $field_name ) = @_; - my $url = sprintf( "%s/field/bug", $self->{base_url} ); + my $url = sprintf( "%s/field/bug", $self->{base_url} ); my $response = $self->{ua}->get($url); if ( !$response->is_success ) { @@ -178,14 +177,14 @@ sub get_field_values { } my $data = decode_json( $response->content ); - + # Find the field in the fields array for my $field ( @{ $data->{fields} || [] } ) { if ( $field->{name} eq $field_name && $field->{values} ) { return [ map { $_->{name} } @{ $field->{values} } ]; } } - + return []; } @@ -195,7 +194,7 @@ sub obsolete_attachment { my $url = sprintf( "%s/bug/attachment/%s", $self->{base_url}, $attachment_id ); my $payload = { - ids => [$attachment_id], + ids => [$attachment_id], is_obsolete => JSON::true, }; @@ -215,4 +214,4 @@ sub obsolete_attachment { return decode_json( $response->content ); } -1; +1; \ No newline at end of file -- 2.51.0