summaryrefslogtreecommitdiffstats
path: root/lib/Devscripts
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-15 20:32:59 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-15 20:32:59 +0000
commit4d57e0a8dab2139a631a21aab862487481548702 (patch)
treef7cea0b9939e2ecb7a301de6c83bada29452046d /lib/Devscripts
parentInitial commit. (diff)
downloaddevscripts-4d57e0a8dab2139a631a21aab862487481548702.tar.xz
devscripts-4d57e0a8dab2139a631a21aab862487481548702.zip
Adding upstream version 2.23.7.upstream/2.23.7upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r--lib/Devscripts/Compression.pm136
-rw-r--r--lib/Devscripts/Config.pm418
-rw-r--r--lib/Devscripts/DB_File_Lock.pm364
-rw-r--r--lib/Devscripts/Debbugs.pm481
-rw-r--r--lib/Devscripts/JSONCache.pm97
-rw-r--r--lib/Devscripts/MkOrigtargz.pm627
-rw-r--r--lib/Devscripts/MkOrigtargz/Config.pm244
-rw-r--r--lib/Devscripts/Output.pm83
-rw-r--r--lib/Devscripts/PackageDeps.pm307
-rw-r--r--lib/Devscripts/Packages.pm313
-rwxr-xr-xlib/Devscripts/Salsa.pm427
-rwxr-xr-xlib/Devscripts/Salsa/Config.pm524
-rw-r--r--lib/Devscripts/Salsa/Hooks.pm314
-rwxr-xr-xlib/Devscripts/Salsa/Repo.pm73
-rw-r--r--lib/Devscripts/Salsa/add_user.pm40
-rwxr-xr-xlib/Devscripts/Salsa/check_repo.pm224
-rw-r--r--lib/Devscripts/Salsa/checkout.pm79
-rw-r--r--lib/Devscripts/Salsa/create_repo.pm47
-rw-r--r--lib/Devscripts/Salsa/del_repo.pm26
-rw-r--r--lib/Devscripts/Salsa/del_user.pm32
-rw-r--r--lib/Devscripts/Salsa/fork.pm36
-rw-r--r--lib/Devscripts/Salsa/forks.pm45
-rw-r--r--lib/Devscripts/Salsa/group.pm35
-rw-r--r--lib/Devscripts/Salsa/join.pm20
-rw-r--r--lib/Devscripts/Salsa/last_ci_status.pm77
-rw-r--r--lib/Devscripts/Salsa/list_groups.pm40
-rw-r--r--lib/Devscripts/Salsa/list_repos.pm42
-rw-r--r--lib/Devscripts/Salsa/merge_request.pm174
-rw-r--r--lib/Devscripts/Salsa/merge_requests.pm49
-rwxr-xr-xlib/Devscripts/Salsa/pipeline_schedule.pm127
-rwxr-xr-xlib/Devscripts/Salsa/pipeline_schedules.pm73
-rw-r--r--lib/Devscripts/Salsa/protect_branch.pm43
-rw-r--r--lib/Devscripts/Salsa/protected_branches.pm27
-rw-r--r--lib/Devscripts/Salsa/purge_cache.pm15
-rw-r--r--lib/Devscripts/Salsa/push.pm106
-rw-r--r--lib/Devscripts/Salsa/push_repo.pm71
-rw-r--r--lib/Devscripts/Salsa/rename_branch.pm47
-rw-r--r--lib/Devscripts/Salsa/search_group.pm37
-rw-r--r--lib/Devscripts/Salsa/search_project.pm57
-rw-r--r--lib/Devscripts/Salsa/search_user.pm36
-rwxr-xr-xlib/Devscripts/Salsa/update_repo.pm137
-rw-r--r--lib/Devscripts/Salsa/update_safe.pm22
-rw-r--r--lib/Devscripts/Salsa/update_user.pm38
-rw-r--r--lib/Devscripts/Salsa/whoami.pm24
-rw-r--r--lib/Devscripts/Set.pm126
-rw-r--r--lib/Devscripts/Uscan/CatchRedirections.pm27
-rw-r--r--lib/Devscripts/Uscan/Config.pm392
-rw-r--r--lib/Devscripts/Uscan/Ctype/nodejs.pm36
-rw-r--r--lib/Devscripts/Uscan/Ctype/perl.pm36
-rw-r--r--lib/Devscripts/Uscan/Downloader.pm303
-rw-r--r--lib/Devscripts/Uscan/FindFiles.pm257
-rw-r--r--lib/Devscripts/Uscan/Keyring.pm168
-rw-r--r--lib/Devscripts/Uscan/Output.pm129
-rw-r--r--lib/Devscripts/Uscan/Utils.pm475
-rw-r--r--lib/Devscripts/Uscan/WatchFile.pm517
-rw-r--r--lib/Devscripts/Uscan/WatchLine.pm1850
-rw-r--r--lib/Devscripts/Uscan/_vcs.pm93
-rw-r--r--lib/Devscripts/Uscan/_xtp.pm90
-rw-r--r--lib/Devscripts/Uscan/ftp.pm280
-rw-r--r--lib/Devscripts/Uscan/git.pm172
-rw-r--r--lib/Devscripts/Uscan/http.pm510
-rw-r--r--lib/Devscripts/Uscan/svn.pm67
-rw-r--r--lib/Devscripts/Utils.pm31
-rw-r--r--lib/Devscripts/Versort.pm60
64 files changed, 11853 insertions, 0 deletions
diff --git a/lib/Devscripts/Compression.pm b/lib/Devscripts/Compression.pm
new file mode 100644
index 0000000..ef27e58
--- /dev/null
+++ b/lib/Devscripts/Compression.pm
@@ -0,0 +1,136 @@
+# Copyright James McCoy <jamessan@debian.org> 2013.
+# Modifications copyright 2002 Julian Gilbey <jdg@debian.org>
+
+# 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 2 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 <https://www.gnu.org/licenses/>.
+
+package Devscripts::Compression;
+
+use Dpkg::Compression qw(
+ !compression_get_file_extension
+ !compression_get_cmdline_compress
+ !compression_get_cmdline_decompress
+);
+use Dpkg::IPC;
+use Exporter qw(import);
+
+our @EXPORT = (
+ @Dpkg::Compression::EXPORT,
+ qw(
+ compression_get_file_extension
+ compression_get_cmdline_compress
+ compression_get_cmdline_decompress
+ compression_guess_from_file
+ ),
+);
+
+eval {
+ Dpkg::Compression->VERSION(2.01);
+ 1;
+} or do {
+ # Ensure we have the compression getters, regardless of the version of
+ # Dpkg::Compression to ease backporting.
+ *{'Dpkg::Compression::compression_get_file_extension'} = sub {
+ my $comp = shift;
+ return compression_get_property($comp, 'file_ext');
+ };
+ *{'Dpkg::Compression::compression_get_cmdline_compress'} = sub {
+ my $comp = shift;
+ my @prog = @{ compression_get_property($comp, 'comp_prog') };
+ push @prog, '-' . compression_get_property($comp, 'default_level');
+ return @prog;
+ };
+ *{'Dpkg::Compression::compression_get_cmdline_decompress'} = sub {
+ my $comp = shift;
+ my @prog = @{ compression_get_property($comp, 'decomp_prog') };
+ return @prog;
+ };
+};
+
+# This can potentially be moved to Dpkg::Compression
+
+my %mime2comp = (
+ "application/x-gzip" => "gzip",
+ "application/gzip" => "gzip",
+ "application/x-bzip2" => "bzip2",
+ "application/bzip2 " => "bzip2",
+ "application/x-xz" => "xz",
+ "application/xz" => "xz",
+ "application/zip" => "zip",
+ "application/x-compress" => "compress",
+ "application/java-archive" => "zip",
+ "application/x-tar" => "tar",
+ "application/zstd" => "zst",
+ "application/x-zstd" => "zst",
+);
+
+sub compression_guess_from_file {
+ my $filename = shift;
+ my $mimetype;
+ spawn(
+ exec => ['file', '--dereference', '--brief', '--mime-type', $filename],
+ to_string => \$mimetype,
+ wait_child => 1
+ );
+ chomp($mimetype);
+ if (exists $mime2comp{$mimetype}) {
+ return $mime2comp{$mimetype};
+ } else {
+ return;
+ }
+}
+
+# comp_prog and default_level aren't provided because a) they aren't needed in
+# devscripts and b) the Dpkg::Compression API isn't rich enough to support
+# these as compressors
+my %comp_properties = (
+ compress => {
+ file_ext => 'Z',
+ decomp_prog => ['uncompress'],
+ },
+ zip => {
+ file_ext => 'zip',
+ decomp_prog => ['unzip'],
+ },
+ zst => {
+ file_ext => 'zst',
+ #comp_prog => ['zstd'],
+ decomp_prog => ['unzstd'],
+ default_level => 3,
+ });
+
+sub compression_get_file_extension {
+ my $comp = shift;
+ if (!exists $comp_properties{$comp}) {
+ return Dpkg::Compression::compression_get_file_extension($comp);
+ }
+ return $comp_properties{$comp}{file_ext};
+}
+
+sub compression_get_cmdline_compress {
+ my $comp = shift;
+ if (!exists $comp_properties{$comp}) {
+ return Dpkg::Compression::compression_get_cmdline_compress($comp);
+ }
+ return @{ $comp_properties{$comp}{comp_prog} };
+}
+
+sub compression_get_cmdline_decompress {
+ my $comp = shift;
+ if (!exists $comp_properties{$comp}) {
+ return Dpkg::Compression::compression_get_cmdline_decompress($comp);
+ }
+ return @{ $comp_properties{$comp}{decomp_prog} };
+}
+
+1;
diff --git a/lib/Devscripts/Config.pm b/lib/Devscripts/Config.pm
new file mode 100644
index 0000000..98f03c7
--- /dev/null
+++ b/lib/Devscripts/Config.pm
@@ -0,0 +1,418 @@
+
+=head1 NAME
+
+Devscripts::Config - devscripts Perl scripts configuration object
+
+=head1 SYNOPSIS
+
+ # Configuration module
+ package Devscripts::My::Config;
+ use Moo;
+ extends 'Devscripts::Config';
+
+ use constant keys => [
+ [ 'text1=s', 'MY_TEXT', qr/^\S/, 'Default_text' ],
+ # ...
+ ];
+
+ has text1 => ( is => 'rw' );
+
+ # Main package or script
+ package Devscripts::My;
+
+ use Moo;
+ my $config = Devscripts::My::Config->new->parse;
+ 1;
+
+=head1 DESCRIPTION
+
+Devscripts Perl scripts configuration object. It can scan configuration files
+(B</etc/devscripts.conf> and B<~/.devscripts>) and command line arguments.
+
+A devscripts configuration package has just to declare:
+
+=over
+
+=item B<keys> constant: array ref I<(see below)>
+
+=item B<rules> constant: hash ref I<(see below)>
+
+=back
+
+=head1 KEYS
+
+Each element of B<keys> constant is an array containing four elements which can
+be undefined:
+
+=over
+
+=item the string to give to L<Getopt::Long>
+
+=item the name of the B<devscripts.conf> key
+
+=item the rule to check value. It can be:
+
+=over
+
+=item B<regexp> ref: will be applied to the value. If it fails against the
+devscripts.conf value, Devscripts::Config will warn. If it fails against the
+command line argument, Devscripts::Config will die.
+
+=item B<sub> ref: function will be called with 2 arguments: current config
+object and proposed value. Function must return a true value to continue or
+0 to stop. This is not simply a "check" function: Devscripts::Config will not
+do anything else than read the result to continue with next argument or stop.
+
+=item B<"bool"> string: means that value is a boolean. devscripts.conf value
+can be either "yes", 1, "no", 0.
+
+=back
+
+=item the default value
+
+=back
+
+=head2 RULES
+
+It is possible to declare some additional rules to check the logic between
+options:
+
+ use constant rules => [
+ sub {
+ my($self)=@_;
+ # OK
+ return 1 if( $self->a < $self->b );
+ # OK with warning
+ return ( 1, 'a should be lower than b ) if( $self->a > $self->b );
+ # NOK with an error
+ return ( 0, 'a must not be equal to b !' );
+ },
+ sub {
+ my($self)=@_;
+ # ...
+ return 1;
+ },
+ ];
+
+=head1 METHODS
+
+=head2 new()
+
+Constructor
+
+=cut
+
+package Devscripts::Config;
+
+use strict;
+use Devscripts::Output;
+use Dpkg::IPC;
+use File::HomeDir;
+use Getopt::Long qw(:config bundling permute no_getopt_compat);
+use Moo;
+
+# Common options
+has common_opts => (
+ is => 'ro',
+ default => sub {
+ [[
+ 'help', undef,
+ sub {
+ if ($_[1]) { $_[0]->usage; exit 0 }
+ }
+ ]]
+ });
+
+# Internal attributes
+
+has modified_conf_msg => (is => 'rw', default => sub { '' });
+
+$ENV{HOME} = File::HomeDir->my_home;
+
+our @config_files
+ = ('/etc/devscripts.conf', ($ENV{HOME} ? "$ENV{HOME}/.devscripts" : ()));
+
+sub keys {
+ die "conffile_keys() must be defined in sub classes";
+}
+
+=head2 parse()
+
+Launches B<parse_conf_files()>, B<parse_command_line()> and B<check_rules>
+
+=cut
+
+sub BUILD {
+ my ($self) = @_;
+ $self->set_default;
+}
+
+sub parse {
+ my ($self) = @_;
+
+ # 1 - Parse /etc/devscripts.conf and ~/.devscripts
+ $self->parse_conf_files;
+
+ # 2 - Parse command line
+ $self->parse_command_line;
+
+ # 3 - Check rules
+ $self->check_rules;
+ return $self;
+}
+
+# I - Parse /etc/devscripts.conf and ~/.devscripts
+
+=head2 parse_conf_files()
+
+Reads values in B</etc/devscripts.conf> and B<~/.devscripts>
+
+=cut
+
+sub set_default {
+ my ($self) = @_;
+ my $keys = $self->keys;
+ foreach my $key (@$keys) {
+ my ($kname, $name, $check, $default) = @$key;
+ next unless (defined $default);
+ $kname =~ s/^\-\-//;
+ $kname =~ s/-/_/g;
+ $kname =~ s/[!\|=].*$//;
+ if (ref $default) {
+ unless (ref $default eq 'CODE') {
+ die "Default value must be a sub ($kname)";
+ }
+ $self->{$kname} = $default->();
+ } else {
+ $self->{$kname} = $default;
+ }
+ }
+}
+
+sub parse_conf_files {
+ my ($self) = @_;
+
+ my @cfg_files = @config_files;
+ if (@ARGV) {
+ if ($ARGV[0] =~ /^--no-?conf$/) {
+ $self->modified_conf_msg(" (no configuration files read)");
+ shift @ARGV;
+ return $self;
+ }
+ my @tmp;
+ while ($ARGV[0] and $ARGV[0] =~ s/^--conf-?file(?:=(.+))?//) {
+ shift @ARGV;
+ my $file = $1 || shift(@ARGV);
+ if ($file) {
+ unless ($file =~ s/^\+//) {
+ @cfg_files = ();
+ }
+ push @tmp, $file;
+ } else {
+ return ds_die
+ "Unable to parse --conf-file option, aborting parsing";
+ }
+ }
+ push @cfg_files, @tmp;
+ }
+
+ @cfg_files = grep { -r $_ } @cfg_files;
+ my $keys = $self->keys;
+ if (@cfg_files) {
+ my @key_names = map { $_->[1] ? $_->[1] : () } @$keys;
+ my %config_vars;
+
+ my $shell_cmd = q{for file ; do . "$file"; done ;};
+
+ # Read back values
+ $shell_cmd .= q{ printf '%s\0' };
+ my @shell_key_names = map { qq{"\$$_"} } @key_names;
+ $shell_cmd .= join(' ', @shell_key_names);
+ my $shell_out;
+ spawn(
+ exec => [
+ '/bin/bash', '-c',
+ $shell_cmd, 'devscripts-config-loader',
+ @cfg_files
+ ],
+ wait_child => 1,
+ to_string => \$shell_out
+ );
+ @config_vars{@key_names} = map { s/^\s*(.*?)\s*/$1/ ? $_ : undef }
+ split(/\0/, $shell_out, -1);
+
+ # Check validity and set value
+ foreach my $key (@$keys) {
+ my ($kname, $name, $check, $default) = @$key;
+ next unless ($name);
+ $kname //= '';
+ $kname =~ s/^\-\-//;
+ $kname =~ s/-/_/g;
+ $kname =~ s/[!|=+].*$//;
+ # Case 1: nothing in conf files, set default
+ next unless (length $config_vars{$name});
+ if (defined $check) {
+ if (not(ref $check)) {
+ $check
+ = $self->_subs_check($check, $kname, $name, $default);
+ }
+ if (ref $check eq 'CODE') {
+ my ($res, $msg)
+ = $check->($self, $config_vars{$name}, $kname);
+ ds_warn $msg unless ($res);
+ next;
+ } elsif (ref $check eq 'Regexp') {
+ unless ($config_vars{$name} =~ $check) {
+ ds_warn "Bad $name value $config_vars{$name}";
+ next;
+ }
+ } else {
+ ds_die "Unknown check type for $name";
+ return undef;
+ }
+ }
+ $self->{$kname} = $config_vars{$name};
+ $self->{modified_conf_msg} .= " $name=$config_vars{$name}\n";
+ if (ref $default) {
+ my $ref = ref $default->();
+ my @tmp = ($config_vars{$name} =~ /\s+"([^"]*)"(?>\s+)/g);
+ $config_vars{$name} =~ s/\s+"([^"]*)"\s+/ /g;
+ push @tmp, split(/\s+/, $config_vars{$name});
+ if ($ref eq 'ARRAY') {
+ $self->{$kname} = \@tmp;
+ } elsif ($ref eq 'HASH') {
+ $self->{$kname}
+ = { map { /^(.*?)=(.*)$/ ? ($1 => $2) : ($_ => 1) }
+ @tmp };
+ }
+ }
+ }
+ }
+ return $self;
+}
+
+# II - Parse command line
+
+=head2 parse_command_line()
+
+Parse command line arguments
+
+=cut
+
+sub parse_command_line {
+ my ($self, @arrays) = @_;
+ my $opts = {};
+ my $keys = [@{ $self->common_opts }, @{ $self->keys }];
+ # If default value is set to [], we must prepare hash ref to be able to
+ # receive more than one value
+ foreach (@$keys) {
+ if ($_->[3] and ref($_->[3])) {
+ my $kname = $_->[0];
+ $kname =~ s/[!\|=].*$//;
+ $opts->{$kname} = $_->[3]->();
+ }
+ }
+ unless (GetOptions($opts, map { $_->[0] ? ($_->[0]) : () } @$keys)) {
+ $_[0]->usage;
+ exit 1;
+ }
+ foreach my $key (@$keys) {
+ my ($kname, $tmp, $check, $default) = @$key;
+ next unless ($kname);
+ $kname =~ s/[!|=+].*$//;
+ my $name = $kname;
+ $kname =~ s/-/_/g;
+ if (defined $opts->{$name}) {
+ next if (ref $opts->{$name} eq 'ARRAY' and !@{ $opts->{$name} });
+ next if (ref $opts->{$name} eq 'HASH' and !%{ $opts->{$name} });
+ if (defined $check) {
+ if (not(ref $check)) {
+ $check
+ = $self->_subs_check($check, $kname, $name, $default);
+ }
+ if (ref $check eq 'CODE') {
+ my ($res, $msg) = $check->($self, $opts->{$name}, $kname);
+ ds_die "Bad value for $name: $msg" unless ($res);
+ } elsif (ref $check eq 'Regexp') {
+ if ($opts->{$name} =~ $check) {
+ $self->{$kname} = $opts->{$name};
+ } else {
+ ds_die "Bad $name value in command line";
+ }
+ } else {
+ ds_die "Unknown check type for $name";
+ }
+ } else {
+ $self->{$kname} = $opts->{$name};
+ }
+ }
+ }
+ return $self;
+}
+
+sub check_rules {
+ my ($self) = @_;
+ if ($self->can('rules')) {
+ if (my $rules = $self->rules) {
+ my $i = 0;
+ foreach my $sub (@$rules) {
+ $i++;
+ my ($res, $msg) = $sub->($self);
+ if ($res) {
+ ds_warn($msg) if ($msg);
+ } else {
+ ds_error($msg || "config rule $i");
+ # ds_error may not die if $Devscripts::Output::die_on_error
+ # is set to 0
+ next;
+ }
+ }
+ }
+ }
+ return $self;
+}
+
+sub _subs_check {
+ my ($self, $check, $kname, $name, $default) = @_;
+ if ($check eq 'bool') {
+ $check = sub {
+ $_[0]->{$kname} = (
+ $_[1] =~ /^(?:1|yes)$/i ? 1
+ : $_[1] =~ /^(?:0|no)$/i ? 0
+ : $default ? $default
+ : undef
+ );
+ return 1;
+ };
+ } else {
+ $self->die("Unknown check type for $name");
+ }
+ return $check;
+}
+
+# Default usage: switch to manpage
+sub usage {
+ $progname =~ s/\.pl//;
+ exec("man", '-P', '/bin/cat', $progname);
+}
+
+1;
+__END__
+=head1 SEE ALSO
+
+L<devscripts>
+
+=head1 AUTHOR
+
+Xavier Guimard E<lt>yadd@debian.orgE<gt>
+
+=head1 COPYRIGHT AND LICENSE
+
+Copyright 2018 by Xavier Guimard <yadd@debian.org>
+
+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 2 of the License, or
+(at your option) any later version.
+
+=cut
diff --git a/lib/Devscripts/DB_File_Lock.pm b/lib/Devscripts/DB_File_Lock.pm
new file mode 100644
index 0000000..12a4680
--- /dev/null
+++ b/lib/Devscripts/DB_File_Lock.pm
@@ -0,0 +1,364 @@
+#
+# DB_File::Lock
+#
+# by David Harris <dharris@drh.net>
+#
+# Copyright (c) 1999-2000 David R. Harris. All rights reserved.
+# This program is free software; you can redistribute it and/or modify it
+# under the same terms as Perl itself.
+#
+
+# We rename the package so that we don't have to package it separately.
+# package DB_File::Lock;
+package Devscripts::DB_File_Lock;
+
+require 5.004;
+
+use strict;
+use vars qw($VERSION @ISA $locks);
+
+@ISA = qw(DB_File);
+$VERSION = '0.05';
+
+use DB_File ();
+use Fcntl qw(:flock O_RDWR O_RDONLY O_WRONLY O_CREAT);
+use Carp qw(croak carp);
+use Symbol ();
+
+# import function can't be inherited, so this magic required
+sub import {
+ my $ourname = shift;
+ my @imports
+ = @_; # dynamic scoped var, still in scope after package call in eval
+ my $module = caller;
+ my $calling = $ISA[0];
+ eval " package $module; import $calling, \@imports; ";
+}
+
+sub _lock_and_tie {
+ my $package = shift;
+
+ ## Grab the type of tie
+
+ my $tie_type = pop @_;
+
+ ## There are two ways of passing data defined by DB_File
+
+ my $lock_data;
+ my @dbfile_data;
+
+ if (@_ == 5) {
+ $lock_data = pop @_;
+ @dbfile_data = @_;
+ } elsif (@_ == 2) {
+ $lock_data = pop @_;
+ @dbfile_data = @{ $_[0] };
+ } else {
+ croak "invalid number of arguments";
+ }
+
+ ## Decipher the lock_data
+
+ my $mode;
+ my $nonblocking = 0;
+ my $lockfile_name = $dbfile_data[0] . ".lock";
+ my $lockfile_mode;
+
+ if (lc($lock_data) eq "read") {
+ $mode = "read";
+ } elsif (lc($lock_data) eq "write") {
+ $mode = "write";
+ } elsif (ref($lock_data) eq "HASH") {
+ $mode = lc $lock_data->{mode};
+ croak "invalid mode ($mode)" if ($mode ne "read" and $mode ne "write");
+ $nonblocking = $lock_data->{nonblocking};
+ $lockfile_name = $lock_data->{lockfile_name}
+ if (defined $lock_data->{lockfile_name});
+ $lockfile_mode = $lock_data->{lockfile_mode};
+ } else {
+ croak "invalid lock_data ($lock_data)";
+ }
+
+ ## Warn about opening a lockfile for writing when only locking for reading
+
+ # NOTE: This warning disabled for RECNO because RECNO seems to require O_RDWR
+ # even when opening only for reading.
+
+ carp
+"opening with write access when locking only for reading (use O_RDONLY to fix)"
+ if ((
+ $dbfile_data[1] && O_RDWR
+ or $dbfile_data[1] && O_WRONLY
+ ) # any kind of write access
+ and $mode eq "read" # and opening for reading
+ and $tie_type ne "TIEARRAY" # and not RECNO
+ );
+
+ ## Determine the mode of the lockfile, if not given
+
+ # THEORY: if someone can read or write the database file, we must allow
+ # them to read and write the lockfile.
+
+ if (not defined $lockfile_mode) {
+ $lockfile_mode = 0600; # we must be allowed to read/write lockfile
+ $lockfile_mode |= 0060 if ($dbfile_data[2] & 0060);
+ $lockfile_mode |= 0006 if ($dbfile_data[2] & 0006);
+ }
+
+ ## Open the lockfile, lock it, and open the database
+
+ my $lockfile_fh = Symbol::gensym();
+ my $saved_umask = umask(0000) if (umask() & $lockfile_mode);
+ my $open_ok = sysopen($lockfile_fh, $lockfile_name, O_RDWR | O_CREAT,
+ $lockfile_mode);
+ umask($saved_umask) if (defined $saved_umask);
+ $open_ok or croak "could not open lockfile ($lockfile_name)";
+
+ my $flock_flags
+ = ($mode eq "write" ? LOCK_EX : LOCK_SH) | ($nonblocking ? LOCK_NB : 0);
+ if (not flock $lockfile_fh, $flock_flags) {
+ close $lockfile_fh;
+ return undef if ($nonblocking);
+ croak "could not flock lockfile";
+ }
+
+ my $self
+ = $tie_type eq "TIEHASH"
+ ? $package->SUPER::TIEHASH(@_)
+ : $package->SUPER::TIEARRAY(@_);
+ if (not $self) {
+ close $lockfile_fh;
+ return $self;
+ }
+
+ ## Store the info for the DESTROY function
+
+ my $id = "" . $self;
+ $id =~ s/^[^=]+=//; # remove the package name in case re-blessing occurs
+ $locks->{$id} = $lockfile_fh;
+
+ ## Return the object
+
+ return $self;
+}
+
+sub TIEHASH {
+ return _lock_and_tie(@_, 'TIEHASH');
+}
+
+sub TIEARRAY {
+ return _lock_and_tie(@_, 'TIEARRAY');
+}
+
+sub DESTROY {
+ my $self = shift;
+
+ my $id = "" . $self;
+ $id =~ s/^[^=]+=//;
+ my $lockfile_fh = $locks->{$id};
+ delete $locks->{$id};
+
+ $self->SUPER::DESTROY(@_);
+
+ # un-flock not needed, as we close here
+ close $lockfile_fh;
+}
+
+1;
+__END__
+
+=head1 NAME
+
+DB_File::Lock - Locking with flock wrapper for DB_File
+
+=head1 SYNOPSIS
+
+ use DB_File::Lock;
+ use Fcntl qw(:flock O_RDWR O_CREAT);
+
+ $locking = "read";
+ $locking = "write";
+ $locking = {
+ mode => "read",
+ nonblocking => 0,
+ lockfile_name => "/path/to/shared.lock",
+ lockfile_mode => 0600,
+ };
+
+ [$X =] tie %hash, 'DB_File::Lock', $filename, $flags, $mode, $DB_HASH, $locking;
+ [$X =] tie %hash, 'DB_File::Lock', $filename, $flags, $mode, $DB_BTREE, $locking;
+ [$X =] tie @array, 'DB_File::Lock', $filename, $flags, $mode, $DB_RECNO, $locking;
+
+ # or place the DB_File arguments inside a list reference:
+ [$X =] tie %hash, 'DB_File::Lock', [$filename, $flags, $mode, $DB_HASH], $locking;
+
+ ...use the same way as DB_File for the rest of the interface...
+
+=head1 DESCRIPTION
+
+This module provides a wrapper for the DB_File module, adding locking.
+
+When you need locking, simply use this module in place of DB_File and
+add an extra argument onto the tie command specifying if the file should
+be locked for reading or writing.
+
+The alternative is to write code like:
+
+ open(LOCK, "<$db_filename.lock") or die;
+ flock(LOCK, LOCK_SH) or die;
+ tie(%db_hash, 'DB_File', $db_filename, O_RDONLY, 0600, $DB_HASH) or die;
+ ... then read the database ...
+ untie(%db_hash);
+ close(LOCK);
+
+This module lets you write
+
+ tie(%db_hash, 'DB_File::Lock', $db_filename, O_RDONLY, 0600, $DB_HASH, 'read') or die;
+ ... then read the database ...
+ untie(%db_hash);
+
+This is better for two reasons:
+
+(1) Less cumbersome to write.
+
+(2) A fatal exception in the code working on the database which does
+not lead to process termination will probably not close the lockfile
+and therefore cause a dropped lock.
+
+=head1 USAGE DETAILS
+
+Tie to the database file by adding an additional locking argument
+to the list of arguments to be passed through to DB_File, such as:
+
+ tie(%db_hash, 'DB_File::Lock', $db_filename, O_RDONLY, 0600, $DB_HASH, 'read');
+
+or enclose the arguments for DB_File in a list reference:
+
+ tie(%db_hash, 'DB_File::Lock', [$db_filename, O_RDONLY, 0600, $DB_HASH], 'read');
+
+The filename used for the lockfile defaults to "$filename.lock"
+(the filename of the DB_File with ".lock" appended). Using a lockfile
+separate from the database file is recommended because it prevents weird
+interactions with the underlying database file library
+
+The additional locking argument added to the tie call can be:
+
+(1) "read" -- acquires a shared lock for reading
+
+(2) "write" -- acquires an exclusive lock for writing
+
+(3) A hash with the following keys (all optional except for the "mode"):
+
+=over 4
+
+=item mode
+
+the locking mode, "read" or "write".
+
+=item lockfile_name
+
+specifies the name of the lockfile to use. Default
+is "$filename.lock". This is useful for locking multiple resources with
+the same lockfiles.
+
+=item nonblocking
+
+determines if the flock call on the lockfile should
+block waiting for a lock, or if it should return failure if a lock can
+not be immediately attained. If "nonblocking" is set and a lock can not
+be attained, the tie command will fail. Currently, I'm not sure how to
+differentiate this between a failure form the DB_File layer.
+
+=item lockfile_mode
+
+determines the mode for the sysopen call in opening
+the lockfile. The default mode will be formulated to allow anyone that
+can read or write the DB_File permission to read and write the lockfile.
+(This is because some systems may require that one have write access to
+a file to lock it for reading, I understand.) The umask will be prevented
+from applying to this mode.
+
+=back
+
+Note: One may import the same values from DB_File::Lock as one may import
+from DB_File.
+
+=head1 GOOD LOCKING ETIQUETTE
+
+To avoid locking problems, realize that it is B<critical> that you release
+the lock as soon as possible. See the lock as a "hot potato", something
+that you must work with and get rid of as quickly as possible. See the
+sections of code where you have a lock as "critical" sections. Make sure
+that you call "untie" as soon as possible.
+
+It is often better to write:
+
+ # open database file with lock
+ # work with database
+ # lots of processing not related to database
+ # work with database
+ # close database and release lock
+
+as:
+
+ # open database file with lock
+ # work with database
+ # close database and release lock
+
+ # lots of processing not related to database
+
+ # open database file with lock
+ # work with database
+ # close database and release lock
+
+Also realize that when acquiring two locks at the same time, a deadlock
+situation can be caused.
+
+You can enter a deadlock situation if two processes simultaneously try to
+acquire locks on two separate databases. Each has locked only one of
+the databases, and cannot continue without locking the second. Yet this
+will never be freed because it is locked by the other process. If your
+processes all ask for their DB files in the same order, this situation
+cannot occur.
+
+=head1 OTHER LOCKING MODULES
+
+There are three locking wrappers for DB_File in CPAN right now. Each one
+implements locking differently and has different goals in mind. It is
+therefore worth knowing the difference, so that you can pick the right
+one for your application.
+
+Here are the three locking wrappers:
+
+Tie::DB_Lock -- DB_File wrapper which creates copies of the database file
+for read access, so that you have kind of a multiversioning concurrent
+read system. However, updates are still serial. Use for databases where
+reads may be lengthy and consistency problems may occur.
+
+Tie::DB_LockFile -- DB_File wrapper that has the ability to lock and
+unlock the database while it is being used. Avoids the tie-before-flock
+problem by simply re-tie-ing the database when you get or drop a
+lock. Because of the flexibility in dropping and re-acquiring the lock
+in the middle of a session, this can be massaged into a system that will
+work with long updates and/or reads if the application follows the hints
+in the POD documentation.
+
+DB_File::Lock (this module) -- extremely lightweight DB_File wrapper
+that simply flocks a lockfile before tie-ing the database and drops the
+lock after the untie. Allows one to use the same lockfile for multiple
+databases to avoid deadlock problems, if desired. Use for databases where
+updates are reads are quick and simple flock locking semantics are enough.
+
+(This text duplicated in the POD documentation, by the way.)
+
+=head1 AUTHOR
+
+David Harris <dharris@drh.net>
+
+Helpful insight from Stas Bekman <stas@stason.org>
+
+=head1 SEE ALSO
+
+DB_File(3).
+
+=cut
diff --git a/lib/Devscripts/Debbugs.pm b/lib/Devscripts/Debbugs.pm
new file mode 100644
index 0000000..355adc3
--- /dev/null
+++ b/lib/Devscripts/Debbugs.pm
@@ -0,0 +1,481 @@
+# This is Debbugs.pm from the Debian devscripts package
+#
+# Copyright (C) 2008 Adam D. Barratt
+# select() is Copyright (C) 2007 Don Armstrong
+#
+# 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 2 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, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+package Devscripts::Debbugs;
+
+=head1 OPTIONS
+
+=over
+
+=item select [key:value ...]
+
+Uses the SOAP interface to output a list of bugs which match the given
+selection requirements.
+
+The following keys are allowed, and may be given multiple times.
+
+=over 8
+
+=item package
+
+Binary package name.
+
+=item source
+
+Source package name.
+
+=item maintainer
+
+E-mail address of the maintainer.
+
+=item submitter
+
+E-mail address of the submitter.
+
+=item severity
+
+Bug severity.
+
+=item status
+
+Status of the bug.
+
+=item tag
+
+Tags applied to the bug. If I<users> is specified, may include
+usertags in addition to the standard tags.
+
+=item owner
+
+Bug's owner.
+
+=item correspondent
+
+Address of someone who sent mail to the log.
+
+=item affects
+
+Bugs which affect this package.
+
+=item bugs
+
+List of bugs to search within.
+
+=item users
+
+Users to use when looking up usertags.
+
+=item archive
+
+Whether to search archived bugs or normal bugs; defaults to 0
+(i.e. only search normal bugs). As a special case, if archive is
+'both', both archived and unarchived bugs are returned.
+
+=back
+
+For example, to select the set of bugs submitted by
+jrandomdeveloper@example.com and tagged wontfix, one would use
+
+select("submitter:jrandomdeveloper@example.com", "tag:wontfix")
+
+=back
+
+=cut
+
+use strict;
+use warnings;
+
+my $soapurl = 'Debbugs/SOAP/1';
+our $btsurl = 'http://bugs.debian.org/';
+my @errors;
+
+our $soap_timeout;
+
+sub soap_timeout {
+ my $timeout_arg = shift;
+ if (defined $timeout_arg and $timeout_arg =~ m{^[1-9]\d*$}) {
+ $soap_timeout = $timeout_arg;
+ }
+}
+
+sub init_soap {
+ my $soapproxyurl;
+ if ($btsurl =~ m%^https?://(.*)/?$%) {
+ $soapproxyurl = $btsurl . '/';
+ } else {
+ $soapproxyurl = 'http://' . $btsurl . '/';
+ }
+ $soapproxyurl =~ s%//$%/%;
+ $soapproxyurl .= 'cgi-bin/soap.cgi';
+ my %options;
+ if ($soap_timeout) {
+ $options{timeout} = $soap_timeout;
+ }
+ my $soap = SOAP::Lite->uri($soapurl)->proxy($soapproxyurl, %options);
+
+ $soap->transport->env_proxy();
+ $soap->on_fault(\&getSOAPError);
+
+ return $soap;
+}
+
+my $soap_broken;
+
+sub have_soap {
+ return ($soap_broken ? 0 : 1) if defined $soap_broken;
+ eval { require SOAP::Lite; };
+
+ if ($@) {
+ if ($@ =~ m%^Can't locate SOAP/%) {
+ $soap_broken = "the libsoap-lite-perl package is not installed";
+ } else {
+ $soap_broken = "couldn't load SOAP::Lite: $@";
+ }
+ } else {
+ $soap_broken = 0;
+ }
+ return ($soap_broken ? 0 : 1);
+}
+
+sub getSOAPError {
+ my ($soap, $result) = @_;
+ my $err;
+ if (ref($result)) {
+ $err = $result->faultstring;
+ } else {
+ $err = $soap->transport->status;
+ }
+ chomp $err;
+ push @errors, $err;
+
+ return new SOAP::SOM;
+}
+
+sub usertags {
+ die "Couldn't run usertags: $soap_broken\n" unless have_soap();
+
+ my @args = @_;
+
+ my $soap = init_soap();
+ my $usertags = $soap->get_usertag(@_);
+
+ if (@errors or not defined $usertags) {
+ my $error = join("\n", @errors);
+ die "Error retrieving usertags from SOAP server: $error\n";
+ }
+
+ my $result = $usertags->result();
+
+ if (@errors or not defined $result) {
+ my $error = join("\n", @errors);
+ die "Error retrieving usertags from SOAP server: $error\n";
+ }
+
+ return $result;
+}
+
+sub select {
+ die "Couldn't run select: $soap_broken\n" unless have_soap();
+ my @args = @_;
+ my %valid_keys = (
+ package => 'package',
+ pkg => 'package',
+ src => 'src',
+ source => 'src',
+ maint => 'maint',
+ maintainer => 'maint',
+ submitter => 'submitter',
+ from => 'submitter',
+ status => 'status',
+ tag => 'tag',
+ tags => 'tag',
+ usertag => 'tag',
+ usertags => 'tag',
+ owner => 'owner',
+ dist => 'dist',
+ distribution => 'dist',
+ bugs => 'bugs',
+ archive => 'archive',
+ severity => 'severity',
+ correspondent => 'correspondent',
+ affects => 'affects',
+ );
+ my %users;
+ my %search_parameters;
+ my $soap = init_soap();
+ for my $arg (@args) {
+ my ($key, $value) = split /:/, $arg, 2;
+ next unless $key;
+ if (exists $valid_keys{$key}) {
+ if ($valid_keys{$key} eq 'archive') {
+ $search_parameters{ $valid_keys{$key} } = $value
+ if $value;
+ } else {
+ push @{ $search_parameters{ $valid_keys{$key} } }, $value
+ if $value;
+ }
+ } elsif ($key =~ /users?$/) {
+ $users{$value} = 1 if $value;
+ } else {
+ warn "select(): Unrecognised key: $key\n";
+ }
+ }
+ my %usertags;
+ for my $user (keys %users) {
+ my $ut = usertags($user);
+ next unless defined $ut and $ut ne "";
+ for my $tag (keys %{$ut}) {
+ push @{ $usertags{$tag} }, @{ $ut->{$tag} };
+ }
+ }
+ my $bugs = $soap->get_bugs(%search_parameters,
+ (keys %usertags) ? (usertags => \%usertags) : ());
+
+ if (@errors or not defined $bugs) {
+ my $error = join("\n", @errors);
+ die "Error while retrieving bugs from SOAP server: $error\n";
+ }
+
+ my $result = $bugs->result();
+ if (@errors or not defined $result) {
+ my $error = join("\n", @errors);
+ die "Error while retrieving bugs from SOAP server: $error\n";
+ }
+
+ return $result;
+}
+
+sub status {
+ die "Couldn't run status: $soap_broken\n" unless have_soap();
+ my @args = @_;
+
+ my $soap = init_soap();
+
+ my $result = {};
+ while (my @slice = splice(@args, 0, 500)) {
+ my $bugs = $soap->get_status(@slice);
+
+ if (@errors or not defined $bugs) {
+ my $error = join("\n", @errors);
+ die
+ "Error while retrieving bug statuses from SOAP server: $error\n";
+ }
+
+ my $tmp = $bugs->result();
+
+ if (@errors or not defined $tmp) {
+ my $error = join("\n", @errors);
+ die
+ "Error while retrieving bug statuses from SOAP server: $error\n";
+ }
+
+ %$result = (%$result, %$tmp);
+ }
+
+ return $result;
+}
+
+sub versions {
+ die "Couldn't run versions: $soap_broken\n" unless have_soap();
+
+ my @args = @_;
+ my %valid_keys = (
+ package => 'package',
+ pkg => 'package',
+ src => 'source',
+ source => 'source',
+ time => 'time',
+ binary => 'no_source_arch',
+ notsource => 'no_source_arch',
+ archs => 'return_archs',
+ displayarch => 'return_archs',
+ );
+
+ my %search_parameters;
+ my @archs = ();
+ my @dists = ();
+
+ for my $arg (@args) {
+ my ($key, $value) = split /:/, $arg, 2;
+ $value ||= "1";
+ if ($key =~ /^arch(itecture)?$/) {
+ push @archs, $value;
+ } elsif ($key =~ /^dist(ribution)?$/) {
+ push @dists, $value;
+ } elsif (exists $valid_keys{$key}) {
+ $search_parameters{ $valid_keys{$key} } = $value;
+ }
+ }
+
+ $search_parameters{arch} = \@archs if @archs;
+ $search_parameters{dist} = \@dists if @dists;
+
+ my $soap = init_soap();
+
+ my $versions = $soap->get_versions(%search_parameters);
+
+ if (@errors or not defined $versions) {
+ my $error = join("\n", @errors);
+ die
+ "Error while retrieving package versions from SOAP server: $error\n";
+ }
+
+ my $result = $versions->result();
+
+ if (@errors or not defined $result) {
+ my $error = join("\n", @errors);
+ die "Error while retrieivng package versions from SOAP server: $error";
+ }
+
+ return $result;
+}
+
+sub versions_with_arch {
+ die "Couldn't run versions_with_arch: $soap_broken\n" unless have_soap();
+ my @args = @_;
+
+ my $versions = versions(@args, 'displayarch:1');
+
+ if (not defined $versions) {
+ die "Error while retrieivng package versions from SOAP server: $@";
+ }
+
+ return $versions;
+}
+
+sub newest_bugs {
+ die "Couldn't run newest_bugs: $soap_broken\n" unless have_soap();
+ my $count = shift || '';
+
+ return if $count !~ /^\d+$/;
+
+ my $soap = init_soap();
+
+ my $bugs = $soap->newest_bugs($count);
+
+ if (@errors or not defined $bugs) {
+ my $error = join("\n", @errors);
+ die "Error while retrieving newest bug list from SOAP server: $error";
+ }
+
+ my $result = $bugs->result();
+
+ if (@errors or not defined $result) {
+ my $error = join("\n", @errors);
+ die "Error while retrieving newest bug list from SOAP server: $error";
+ }
+
+ return $result;
+}
+
+# debbugs currently ignores the $msg_num parameter
+# but eventually it might not, so we support passing it
+
+sub bug_log {
+ die "Couldn't run bug_log: $soap_broken\n" unless have_soap();
+
+ my $bug = shift || '';
+ my $message = shift;
+
+ return if $bug !~ /^\d+$/;
+
+ my $soap = init_soap();
+
+ my $log = $soap->get_bug_log($bug, $message);
+
+ if (@errors or not defined $log) {
+ my $error = join("\n", @errors);
+ die "Error while retrieving bug log from SOAP server: $error\n";
+ }
+
+ my $result = $log->result();
+
+ if (@errors or not defined $result) {
+ my $error = join("\n", @errors);
+ die "Error while retrieving bug log from SOAP server: $error\n";
+ }
+
+ return $result;
+}
+
+sub binary_to_source {
+ die "Couldn't run binary_to_source: $soap_broken\n"
+ unless have_soap();
+
+ my $soap = init_soap();
+
+ my $binpkg = shift;
+ my $binver = shift;
+ my $arch = shift;
+
+ return if not defined $binpkg or not defined $binver;
+
+ my $mapping = $soap->binary_to_source($binpkg, $binver, $arch);
+
+ if (@errors or not defined $mapping) {
+ my $error = join("\n", @errors);
+ die
+"Error while retrieving binary to source mapping from SOAP server: $error\n";
+ }
+
+ my $result = $mapping->result();
+
+ if (@errors or not defined $result) {
+ my $error = join("\n", @errors);
+ die
+"Error while retrieving binary to source mapping from SOAP server: $error\n";
+ }
+
+ return $result;
+}
+
+sub source_to_binary {
+ die "Couldn't run source_to_binary: $soap_broken\n"
+ unless have_soap();
+
+ my $soap = init_soap();
+
+ my $srcpkg = shift;
+ my $srcver = shift;
+
+ return if not defined $srcpkg or not defined $srcver;
+
+ my $mapping = $soap->source_to_binary($srcpkg, $srcver);
+
+ if (@errors or not defined $mapping) {
+ my $error = join("\n", @errors);
+ die
+"Error while retrieving source to binary mapping from SOAP server: $error\n";
+ }
+
+ my $result = $mapping->result();
+
+ if (@errors or not defined $result) {
+ my $error = join("\n", @errors);
+ die
+"Error while retrieving source to binary mapping from SOAP server: $error\n";
+ }
+
+ return $result;
+}
+
+1;
+
+__END__
+
diff --git a/lib/Devscripts/JSONCache.pm b/lib/Devscripts/JSONCache.pm
new file mode 100644
index 0000000..b2e8761
--- /dev/null
+++ b/lib/Devscripts/JSONCache.pm
@@ -0,0 +1,97 @@
+package Devscripts::JSONCache;
+
+use strict;
+use JSON;
+use Moo;
+
+has file => (is => 'rw', required => 1);
+
+has saved => (is => 'rw');
+
+has _data => (is => 'rw');
+
+sub save_sec {
+ my ($self, $obj) = @_;
+ my $tmp = umask;
+ umask 0177;
+ open(my $fh, '>', $self->file) or ($self->saved(1) and die $!);
+ print $fh JSON::to_json($obj);
+ close $fh;
+ umask $tmp;
+}
+
+sub data {
+ my ($self) = @_;
+ return $self->_data if $self->_data;
+ my $res;
+ if (-r $self->file) {
+ open(F, $self->file) or ($self->saved(1) and die $!);
+ $res = JSON::from_json(join('', <F>) || "{}");
+ close F;
+ } else {
+ $self->save_sec({});
+ $self->saved(0);
+ }
+ return $self->_data($res);
+}
+
+sub TIEHASH {
+ my $r = shift->new({
+ file => shift,
+ @_,
+ });
+ # build data
+ $r->data;
+ return $r;
+}
+
+sub FETCH {
+ return $_[0]->data->{ $_[1] };
+}
+
+sub STORE {
+ $_[0]->data->{ $_[1] } = $_[2];
+}
+
+sub DELETE {
+ delete $_[0]->data->{ $_[1] };
+}
+
+sub CLEAR {
+ $_[0]->save({});
+}
+
+sub EXISTS {
+ return exists $_[0]->data->{ $_[1] };
+}
+
+sub FIRSTKEY {
+ my ($k) = sort { $a cmp $b } keys %{ $_[0]->data };
+ return $k;
+}
+
+sub NEXTKEY {
+ my ($self, $last) = @_;
+ my $i = 0;
+ my @keys = map {
+ return $_ if ($i);
+ $i++ if ($_ eq $last);
+ return ()
+ }
+ sort { $a cmp $b } keys %{ $_[0]->data };
+ return @keys ? $keys[0] : ();
+}
+
+sub SCALAR {
+ return scalar %{ $_[0]->data };
+}
+
+sub save {
+ return if ($_[0]->saved);
+ eval { $_[0]->save_sec($_[0]->data); };
+ $_[0]->saved(1);
+}
+
+*DESTROY = *UNTIE = *save;
+
+1;
diff --git a/lib/Devscripts/MkOrigtargz.pm b/lib/Devscripts/MkOrigtargz.pm
new file mode 100644
index 0000000..2c01969
--- /dev/null
+++ b/lib/Devscripts/MkOrigtargz.pm
@@ -0,0 +1,627 @@
+package Devscripts::MkOrigtargz;
+
+use strict;
+use Cwd 'abs_path';
+use Devscripts::Compression qw/
+ compression_guess_from_file
+ compression_get_file_extension
+ compression_get_cmdline_compress
+ compression_get_cmdline_decompress
+ /;
+use Devscripts::MkOrigtargz::Config;
+use Devscripts::Output;
+use Devscripts::Uscan::Output;
+use Devscripts::Utils;
+use Dpkg::Changelog::Debian;
+use Dpkg::Control::Hash;
+use Dpkg::IPC;
+use Dpkg::Version;
+use File::Copy;
+use File::Spec;
+use File::Temp qw/tempdir/;
+use Moo;
+
+has config => (
+ is => 'rw',
+ default => sub {
+ Devscripts::MkOrigtargz::Config->new->parse;
+ },
+);
+
+has exclude_globs => (
+ is => 'rw',
+ lazy => 1,
+ default => sub { $_[0]->config->exclude_file },
+);
+
+has include_globs => (
+ is => 'rw',
+ lazy => 1,
+ default => sub { $_[0]->config->include_file },
+);
+
+has status => (is => 'rw', default => sub { 0 });
+has destfile_nice => (is => 'rw');
+
+our $found_comp;
+
+sub do {
+ my ($self) = @_;
+ $self->parse_copyrights or $self->make_orig_targz;
+ return $self->status;
+}
+
+sub make_orig_targz {
+ my ($self) = @_;
+
+ # Now we know what the final filename will be
+ my $destfilebase = sprintf "%s_%s.%s.tar", $self->config->package,
+ $self->config->version, $self->config->orig;
+ my $destfiletar = sprintf "%s/%s", $self->config->directory, $destfilebase;
+ my $destext
+ = $self->config->compression eq 'default'
+ ? 'default'
+ : compression_get_file_extension($self->config->compression);
+ my $destfile;
+
+ # $upstream_tar is $upstream, unless the latter was a zip file.
+ my $upstream_tar = $self->config->upstream;
+
+ # Remember this for the final report
+ my $zipfile_deleted = 0;
+
+ # If the file is a zipfile, we need to create a tarfile from it.
+ if ($self->config->upstream_type eq 'zip') {
+ $destfile = $self->fix_dest_file($destfiletar);
+ if ($self->config->signature) {
+ $self->config->signature(4); # repack upstream file
+ }
+
+ my $tempdir = tempdir("uscanXXXX", TMPDIR => 1, CLEANUP => 1);
+ # Parent of the target directory should be under our control
+ $tempdir .= '/repack';
+ my @cmd;
+ unless (mkdir $tempdir) {
+ ds_die("Unable to mkdir($tempdir): $!\n");
+ return $self->status(1);
+ }
+ @cmd = ('unzip', '-q');
+ push @cmd, split ' ', $self->config->unzipopt
+ if defined $self->config->unzipopt;
+ push @cmd, ('-d', $tempdir, $upstream_tar);
+ unless (ds_exec_no_fail(@cmd) >> 8 == 0) {
+ ds_die(
+ "Repacking from zip, jar, or xpi failed (could not unzip)\n");
+ return $self->status(1);
+ }
+
+ # Figure out the top-level contents of the tarball.
+ # If we'd pass "." to tar we'd get the same contents, but the filenames
+ # would start with ./, which is confusing later.
+ # This should also be more reliable than, say, changing directories and
+ # globbing.
+ unless (opendir(TMPDIR, $tempdir)) {
+ ds_die("Can't open $tempdir $!\n");
+ return $self->status(1);
+ }
+ my @files = grep { $_ ne "." && $_ ne ".." } readdir(TMPDIR);
+ close TMPDIR;
+
+ # tar it all up
+ spawn(
+ exec => [
+ 'tar', '--owner=root',
+ '--group=root', '--mode=a+rX',
+ '--create', '--file',
+ "$destfiletar", '--directory',
+ $tempdir, @files
+ ],
+ wait_child => 1
+ );
+ unless (-e "$destfiletar") {
+ ds_die(
+"Repacking from zip or jar to tar.$destext failed (could not create tarball)\n"
+ );
+ return $self->status(1);
+ }
+ eval {
+ compress_archive($destfiletar, $destfile,
+ $self->config->compression);
+ };
+ if ($@) {
+ ds_die($@);
+ return $self->status(1);
+ }
+
+ # rename means the user did not want this file to exist afterwards
+ if ($self->config->mode eq "rename") {
+ unlink $upstream_tar;
+ $zipfile_deleted++;
+ }
+
+ $self->config->mode('repack');
+ $upstream_tar = $destfile;
+ } elsif (compression_guess_from_file($upstream_tar) =~ /^zstd?$/) {
+ $self->config->force_repack(1);
+ }
+
+ # From now on, $upstream_tar is guaranteed to be a compressed tarball. It
+ # is always a full (possibly relative) path, and distinct from $destfile.
+
+ # Find out if we have to repack
+ my $do_repack = 0;
+ if ($self->config->repack) {
+ my $comp = compression_guess_from_file($upstream_tar);
+ unless ($comp) {
+ ds_die("Cannot determine compression method of $upstream_tar");
+ return $self->status(1);
+ }
+ $do_repack = (
+ $comp eq 'tar'
+ or ( $self->config->compression ne 'default'
+ and $comp ne $self->config->compression)
+ or ( $self->config->compression eq 'default'
+ and $comp ne
+ &Devscripts::MkOrigtargz::Config::default_compression));
+ }
+
+ # Removing files
+ my $deletecount = 0;
+ my @to_delete;
+
+ if (@{ $self->exclude_globs }) {
+ my @files;
+ my $files;
+ spawn(
+ exec => ['tar', '-t', '-a', '-f', $upstream_tar],
+ to_string => \$files,
+ wait_child => 1
+ );
+ @files = split /^/, $files;
+ chomp @files;
+
+ my %delete;
+ # find out what to delete
+ my @exclude_info;
+ eval {
+ @exclude_info
+ = map { { glob => $_, used => 0, regex => glob_to_regex($_) } }
+ @{ $self->exclude_globs };
+ };
+ if ($@) {
+ ds_die($@);
+ return $self->status(1);
+ }
+ for my $filename (sort @files) {
+ my $last_match;
+ for my $info (@exclude_info) {
+ if (
+ $filename
+ =~ m@^(?:[^/]*/)? # Possible leading directory, ignore it
+ (?:$info->{regex}) # User pattern
+ (?:/.*)?$ # Possible trailing / for a directory
+ @x
+ ) {
+ if (!$last_match) {
+ # if the current entry is a directory, check if it
+ # matches any exclude-ignored glob
+ my $ignore_this_exclude = 0;
+ for my $ignore_exclude (@{ $self->include_globs }) {
+ my $ignore_exclude_regex
+ = glob_to_regex($ignore_exclude);
+
+ if ($filename =~ $ignore_exclude_regex) {
+ $ignore_this_exclude = 1;
+ last;
+ }
+ if ( $filename =~ m,/$,
+ && $ignore_exclude =~ $info->{regex}) {
+ $ignore_this_exclude = 1;
+ last;
+ }
+ }
+ next if $ignore_this_exclude;
+ $delete{$filename} = 1;
+ }
+ $last_match = $info;
+ }
+ }
+ if (defined $last_match) {
+ $last_match->{used} = 1;
+ }
+ }
+
+ for my $info (@exclude_info) {
+ if (!$info->{used}) {
+ ds_warn
+"No files matched excluded pattern as the last matching glob: $info->{glob}\n";
+ }
+ }
+
+ # ensure files are mentioned before the directory they live in
+ # (otherwise tar complains)
+ @to_delete = sort { $b cmp $a } keys %delete;
+
+ $deletecount = scalar(@to_delete);
+ }
+
+ if ($deletecount or $self->config->force_repack) {
+ $destfilebase = sprintf "%s_%s%s.%s.tar", $self->config->package,
+ $self->config->version, $self->config->repack_suffix,
+ $self->config->orig;
+ $destfiletar = sprintf "%s/%s", $self->config->directory,
+ $destfilebase;
+ $destfile = $self->fix_dest_file($destfiletar);
+
+ # Zip -> tar process already created $destfile, so need to rename it
+ if ($self->config->upstream_type eq 'zip') {
+ move($upstream_tar, $destfile);
+ $upstream_tar = $destfile;
+ }
+ }
+
+ # Actually do the unpack, remove, pack cycle
+ if ($do_repack || $deletecount || $self->config->force_repack) {
+ $destfile ||= $self->fix_dest_file($destfiletar);
+ if ($self->config->signature) {
+ $self->config->signature(4); # repack upstream file
+ }
+ if ($self->config->upstream_comp) {
+ eval { decompress_archive($upstream_tar, $destfiletar) };
+ if ($@) {
+ ds_die($@);
+ return $self->status(1);
+ }
+ } else {
+ copy $upstream_tar, $destfiletar;
+ }
+ unlink $upstream_tar if $self->config->mode eq "rename";
+ # We have to use piping because --delete is broken otherwise, as
+ # documented at
+ # https://www.gnu.org/software/tar/manual/html_node/delete.html
+ if (@to_delete) {
+ # ARG_MAX: max number of bytes exec() can handle
+ my $arg_max;
+ spawn(
+ exec => ['getconf', 'ARG_MAX'],
+ to_string => \$arg_max,
+ wait_child => 1
+ );
+ # Under Hurd `getconf` above returns "undefined".
+ # It's apparently unlimited (?), so we just use a arbitrary number.
+ if ($arg_max =~ /\D/) { $arg_max = 131072; }
+ # Usually NAME_MAX=255, but here we use 128 to be on the safe side.
+ $arg_max = int($arg_max / 128);
+ # We use this lame splice on a totally arbitrary $arg_max because
+ # counting how many bytes there are in @to_delete is too
+ # inefficient.
+ while (my @next_n = splice @to_delete, 0, $arg_max) {
+ spawn(
+ exec => ['tar', '--delete', @next_n],
+ from_file => $destfiletar,
+ to_file => $destfiletar . ".tmp",
+ wait_child => 1
+ ) if scalar(@next_n) > 0;
+ move($destfiletar . ".tmp", $destfiletar);
+ }
+ }
+ eval {
+ compress_archive($destfiletar, $destfile,
+ $self->config->compression);
+ };
+ if ($@) {
+ ds_die $@;
+ return $self->status(1);
+ }
+
+ # Symlink no longer makes sense
+ $self->config->mode('repack');
+ $upstream_tar = $destfile;
+ } else {
+ $destfile = $self->fix_dest_file($destfiletar,
+ compression_guess_from_file($upstream_tar), 1);
+ }
+
+ # Final step: symlink, copy or rename for tarball.
+
+ my $same_name = abs_path($destfile) eq abs_path($self->config->upstream);
+ unless ($same_name) {
+ if ( $self->config->mode ne "repack"
+ and $upstream_tar ne $self->config->upstream) {
+ ds_die "Assertion failed";
+ return $self->status(1);
+ }
+
+ if ($self->config->mode eq "symlink") {
+ my $rel
+ = File::Spec->abs2rel($upstream_tar, $self->config->directory);
+ symlink $rel, $destfile;
+ } elsif ($self->config->mode eq "copy") {
+ copy($upstream_tar, $destfile);
+ } elsif ($self->config->mode eq "rename") {
+ move($upstream_tar, $destfile);
+ }
+ }
+
+ # Final step: symlink, copy or rename for signature file.
+
+ my $destsigfile;
+ if ($self->config->signature == 1) {
+ $destsigfile = sprintf "%s.asc", $destfile;
+ } elsif ($self->config->signature == 2) {
+ $destsigfile = sprintf "%s.asc", $destfiletar;
+ } elsif ($self->config->signature == 3) {
+ # XXX FIXME XXX place holder
+ $destsigfile = sprintf "%s.asc", $destfile;
+ } else {
+ # $self->config->signature == 0 or 4
+ $destsigfile = "";
+ }
+
+ if ($self->config->signature == 1 or $self->config->signature == 2) {
+ my $is_openpgp_ascii_armor = 0;
+ my $fh_sig;
+ unless (open($fh_sig, '<', $self->config->signature_file)) {
+ ds_die "Cannot open $self->{config}->{signature_file}\n";
+ return $self->status(1);
+ }
+ while (<$fh_sig>) {
+ if (m/^-----BEGIN PGP /) {
+ $is_openpgp_ascii_armor = 1;
+ last;
+ }
+ }
+ close($fh_sig);
+
+ if (not $is_openpgp_ascii_armor) {
+ my @enarmor
+ = `gpg --no-options --output - --enarmor $self->{config}->{signature_file} 2>&1`;
+ unless ($? == 0) {
+ ds_die
+"Failed to convert $self->{config}->{signature_file} to *.asc\n";
+ return $self->status(1);
+ }
+ unless (open(DESTSIG, '>', $destsigfile)) {
+ ds_die "Failed to open $destsigfile for write $!\n";
+ return $self->status(1);
+ }
+ foreach my $line (@enarmor) {
+ next if $line =~ m/^Version:/;
+ next if $line =~ m/^Comment:/;
+ $line =~ s/ARMORED FILE/SIGNATURE/;
+ print DESTSIG $line;
+ }
+ unless (close(DESTSIG)) {
+ ds_die
+"Cannot write signature file $self->{config}->{signature_file}\n";
+ return $self->status(1);
+ }
+ } else {
+ if (abs_path($self->config->signature_file) ne
+ abs_path($destsigfile)) {
+ if ($self->config->mode eq "symlink") {
+ my $rel = File::Spec->abs2rel(
+ $self->config->signature_file,
+ $self->config->directory
+ );
+ symlink $rel, $destsigfile;
+ } elsif ($self->config->mode eq "copy") {
+ copy($self->config->signature_file, $destsigfile);
+ } elsif ($self->config->mode eq "rename") {
+ move($self->config->signature_file, $destsigfile);
+ } else {
+ ds_die 'Strange mode="' . $self->config->mode . "\"\n";
+ return $self->status(1);
+ }
+ }
+ }
+ } elsif ($self->config->signature == 3) {
+ uscan_msg_raw
+"Skip adding upstream signature since upstream file has non-detached signature file.";
+ } elsif ($self->config->signature == 4) {
+ uscan_msg_raw
+ "Skip adding upstream signature since upstream file is repacked.";
+ }
+
+ # Final check: Is the tarball usable
+
+ # We are lazy and rely on Dpkg::IPC to report an error message
+ # (spawn does not report back the error code).
+ # We don't expect this to occur often anyways.
+ my $ret = spawn(
+ exec => ['tar', '--list', '--auto-compress', '--file', $destfile],
+ wait_child => 1,
+ to_file => '/dev/null'
+ );
+
+ # Tell the user what we did
+
+ my $upstream_nice = File::Spec->canonpath($self->config->upstream);
+ my $destfile_nice = File::Spec->canonpath($destfile);
+ $self->destfile_nice($destfile_nice);
+
+ if ($same_name) {
+ uscan_msg_raw "Leaving $destfile_nice where it is";
+ } else {
+ if ( $self->config->upstream_type eq 'zip'
+ or $do_repack
+ or $deletecount
+ or $self->config->force_repack) {
+ uscan_msg_raw
+ "Successfully repacked $upstream_nice as $destfile_nice";
+ } elsif ($self->config->mode eq "symlink") {
+ uscan_msg_raw
+ "Successfully symlinked $upstream_nice to $destfile_nice";
+ } elsif ($self->config->mode eq "copy") {
+ uscan_msg_raw
+ "Successfully copied $upstream_nice to $destfile_nice";
+ } elsif ($self->config->mode eq "rename") {
+ uscan_msg_raw
+ "Successfully renamed $upstream_nice to $destfile_nice";
+ } else {
+ ds_die 'Unknown mode ' . $self->config->mode;
+ return $self->status(1);
+ }
+ }
+
+ if ($deletecount) {
+ uscan_msg_raw ", deleting ${deletecount} files from it";
+ }
+ if ($zipfile_deleted) {
+ uscan_msg_raw ", and removed the original file";
+ }
+ uscan_msg_raw ".\n";
+ return 0;
+}
+
+sub decompress_archive {
+ my ($from_file, $to_file) = @_;
+ my $comp = compression_guess_from_file($from_file);
+ unless ($comp) {
+ die("Cannot determine compression method of $from_file");
+ }
+
+ my @cmd = compression_get_cmdline_decompress($comp);
+ spawn(
+ exec => \@cmd,
+ from_file => $from_file,
+ to_file => $to_file,
+ wait_child => 1
+ );
+}
+
+sub compress_archive {
+ my ($from_file, $to_file, $comp) = @_;
+
+ my @cmd = compression_get_cmdline_compress($comp);
+ spawn(
+ exec => \@cmd,
+ from_file => $from_file,
+ to_file => $to_file,
+ wait_child => 1
+ );
+ unlink $from_file;
+}
+
+# Adapted from Text::Glob::glob_to_regex_string
+sub glob_to_regex {
+ my ($glob) = @_;
+
+ if ($glob =~ m@/$@) {
+ ds_warn
+ "Files-Excluded pattern ($glob) should not have a trailing /\n";
+ chop($glob);
+ }
+ if ($glob =~ m/(?<!\\)(?:\\{2})*\\(?![\\*?])/) {
+ die
+"Invalid Files-Excluded pattern ($glob), \\ can only escape \\, *, or ? characters\n";
+ }
+
+ my ($regex, $escaping);
+ for my $c ($glob =~ m/(.)/gs) {
+ if (
+ $c eq '.'
+ || $c eq '('
+ || $c eq ')'
+ || $c eq '|'
+ || $c eq '+'
+ || $c eq '^'
+ || $c eq '$'
+ || $c eq '@'
+ || $c eq '%'
+ || $c eq '{'
+ || $c eq '}'
+ || $c eq '['
+ || $c eq ']'
+ ||
+
+ # Escape '#' since we're using /x in the pattern match
+ $c eq '#'
+ ) {
+ $regex .= "\\$c";
+ } elsif ($c eq '*') {
+ $regex .= $escaping ? "\\*" : ".*";
+ } elsif ($c eq '?') {
+ $regex .= $escaping ? "\\?" : ".";
+ } elsif ($c eq "\\") {
+ if ($escaping) {
+ $regex .= "\\\\";
+ $escaping = 0;
+ } else {
+ $escaping = 1;
+ }
+ next;
+ } else {
+ $regex .= $c;
+ $escaping = 0;
+ }
+ $escaping = 0;
+ }
+
+ return $regex;
+}
+
+sub parse_copyrights {
+ my ($self) = @_;
+ for my $copyright_file (@{ $self->config->copyright_file }) {
+ my $data = Dpkg::Control::Hash->new();
+ my $okformat
+ = qr'https?://www.debian.org/doc/packaging-manuals/copyright-format/[.\d]+';
+ eval {
+ $data->load($copyright_file);
+ 1;
+ } or do {
+ undef $data;
+ };
+ if (not -e $copyright_file) {
+ ds_die "File $copyright_file not found.";
+ return $self->status(1);
+ } elsif ($data
+ && defined $data->{format}
+ && $data->{format} =~ m@^$okformat/?$@) {
+ if ($data->{ $self->config->excludestanza }) {
+ push(
+ @{ $self->exclude_globs },
+ grep { $_ }
+ split(/\s+/, $data->{ $self->config->excludestanza }));
+ }
+ if ($data->{ $self->config->includestanza }) {
+ push(
+ @{ $self->include_globs },
+ grep { $_ }
+ split(/\s+/, $data->{ $self->config->includestanza }));
+ }
+ } else {
+ if (open my $file, '<', $copyright_file) {
+ while (my $line = <$file>) {
+ if ($line =~ m/\b$self->{config}->{excludestanza}.*:/i) {
+ ds_warn "The file $copyright_file mentions "
+ . $self->config->excludestanza
+ . ", but its "
+ . "format is not recognized. Specify Format: "
+ . "https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ "
+ . "in order to remove files from the tarball with mk-origtargz.\n";
+ last;
+ }
+ }
+ close $file;
+ } else {
+ ds_die "Unable to read $copyright_file: $!\n";
+ return $self->status(1);
+ }
+ }
+ }
+}
+
+sub fix_dest_file {
+ my ($self, $destfiletar, $comp, $force) = @_;
+ if ($self->config->compression eq 'default' or $force) {
+ $self->config->compression($comp
+ || &Devscripts::MkOrigtargz::Config::default_compression);
+ }
+ $comp = compression_get_file_extension($self->config->compression);
+ $found_comp ||= $self->config->compression;
+ return sprintf "%s.%s", $destfiletar, $comp;
+}
+
+1;
diff --git a/lib/Devscripts/MkOrigtargz/Config.pm b/lib/Devscripts/MkOrigtargz/Config.pm
new file mode 100644
index 0000000..e2736c9
--- /dev/null
+++ b/lib/Devscripts/MkOrigtargz/Config.pm
@@ -0,0 +1,244 @@
+package Devscripts::MkOrigtargz::Config;
+
+use strict;
+
+use Devscripts::Compression qw'compression_is_supported
+ compression_guess_from_file';
+use Devscripts::Uscan::Output;
+use Exporter 'import';
+use File::Which;
+use Moo;
+
+use constant default_compression => 'xz';
+
+# regexp-assemble << END
+# tar\.gz
+# tgz
+# tar\.bz2
+# tbz2?
+# tar\.lzma
+# tlz(?:ma?)?
+# tar\.xz
+# txz
+# tar\.Z
+# tar
+# tar.zst
+# tar.zstd
+# END
+use constant tar_regex =>
+ qr/t(?:ar(?:\.(?:[gx]z|lzma|bz2|Z)|.zstd?)?|lz(?:ma?)?|[gx]z|bz2?)$/;
+
+extends 'Devscripts::Config';
+
+# Command-line parameters
+has component => (is => 'rw');
+has compression => (is => 'rw');
+has copyright_file => (is => 'rw');
+has directory => (is => 'rw');
+has exclude_file => (is => 'rw');
+has include_file => (is => 'rw');
+has force_repack => (is => 'rw');
+has package => (is => 'rw');
+has signature => (is => 'rw');
+has signature_file => (is => 'rw');
+has repack => (is => 'rw');
+has repack_suffix => (is => 'rw');
+has unzipopt => (is => 'rw');
+has version => (is => 'rw');
+
+# Internal accessors
+has mode => (is => 'rw');
+has orig => (is => 'rw', default => sub { 'orig' });
+has excludestanza => (is => 'rw', default => sub { 'Files-Excluded' });
+has includestanza => (is => 'rw', default => sub { 'Files-Included' });
+has upstream => (is => 'rw');
+has upstream_type => (is => 'rw');
+has upstream_comp => (is => 'rw');
+
+use constant keys => [
+ ['package=s'],
+ ['version|v=s'],
+ [
+ 'component|c=s',
+ undef,
+ sub {
+ if ($_[1]) {
+ $_[0]->orig("orig-$_[1]");
+ $_[0]->excludestanza("Files-Excluded-$_[1]");
+ $_[0]->includestanza("Files-Included-$_[1]");
+ }
+ 1;
+
+ }
+ ],
+ ['directory|C=s'],
+ ['exclude-file=s', undef, undef, sub { [] }],
+ ['include-file=s', undef, undef, sub { [] }],
+ ['force-repack'],
+ ['copyright-file=s', undef, undef, sub { [] }],
+ ['signature=i', undef, undef, 0],
+ ['signature-file=s', undef, undef, ''],
+ [
+ 'compression=s',
+ undef,
+ sub {
+ return (0, "Unknown compression scheme $_[1]")
+ unless ($_[1] eq 'default' or compression_is_supported($_[1]));
+ $_[0]->compression($_[1]);
+ },
+ ],
+ ['symlink', undef, \&setmode],
+ ['rename', undef, \&setmode],
+ ['copy', undef, \&setmode],
+ ['repack'],
+ ['repack-suffix|S=s', undef, undef, ''],
+ ['unzipopt=s'],
+];
+
+use constant rules => [
+ # Check --package if --version is used
+ sub {
+ return (
+ (defined $_[0]->{package} and not defined $_[0]->{version})
+ ? (0, 'If you use --package, you also have to specify --version')
+ : (1));
+ },
+ # Check that a tarball has been given and store it in $self->upstream
+ sub {
+ return (0, 'Please specify original tarball') unless (@ARGV == 1);
+ $_[0]->upstream($ARGV[0]);
+ return (
+ -r $_[0]->upstream
+ ? (1)
+ : (0, "Could not read $_[0]->{upstream}: $!"));
+ },
+ # Get Debian package name an version unless given
+ sub {
+ my ($self) = @_;
+ unless (defined $self->package) {
+
+ # get package name
+ my $c = Dpkg::Changelog::Debian->new(range => { count => 1 });
+ $c->load('debian/changelog');
+ if (my $msg = $c->get_parse_errors()) {
+ return (0, "could not parse debian/changelog:\n$msg");
+ }
+ my ($entry) = @{$c};
+ $self->package($entry->get_source());
+
+ # get version number
+ unless (defined $self->version) {
+ my $debversion = Dpkg::Version->new($entry->get_version());
+ if ($debversion->is_native()) {
+ return (0,
+ "Package with native version number $debversion; "
+ . "mk-origtargz makes no sense for native packages."
+ );
+ }
+ $self->version($debversion->version());
+ }
+
+ unshift @{ $self->copyright_file }, "debian/copyright"
+ if -r "debian/copyright";
+
+ # set destination directory
+ unless (defined $self->directory) {
+ $self->directory('..');
+ }
+ } else {
+ unless (defined $self->directory) {
+ $self->directory('.');
+ }
+ }
+ return 1;
+ },
+ # Get upstream type and compression
+ sub {
+ my ($self) = @_;
+ my $mime = compression_guess_from_file($self->upstream);
+
+ if (defined $mime and $mime eq 'zip') {
+ $self->upstream_type('zip');
+ my ($prog, $pkg);
+ if ($self->upstream =~ /\.xpi$/i) {
+ $self->upstream_comp('xpi');
+ } else {
+ $self->upstream_comp('zip');
+ }
+ $prog = $pkg = 'unzip';
+ return (0,
+ "$prog binary not found."
+ . " You need to install the package $pkg"
+ . " to be able to repack "
+ . $self->upstream_comp
+ . " upstream archives.\n")
+ unless (which $prog);
+ } elsif ($self->upstream =~ tar_regex) {
+ $self->upstream_type('tar');
+ if ($self->upstream =~ /\.tar$/) {
+ $self->upstream_comp('');
+ } else {
+ unless (
+ $self->upstream_comp(
+ compression_guess_from_file($self->upstream))
+ ) {
+ return (0,
+ "Unknown compression used in $self->{upstream}");
+ }
+ }
+ } else {
+ # TODO: Should we ignore the name and only look at what file knows?
+ return (0,
+ 'Parameter '
+ . $self->upstream
+ . ' does not look like a tar archive or a zip file.');
+ }
+ return 1;
+ },
+ # Default compression
+ sub {
+ my ($self) = @_;
+
+ # Case 1: format is 1.0
+ if (-r 'debian/source/format') {
+ open F, 'debian/source/format';
+ my $str = <F>;
+ unless ($str =~ /^([\d\.]+)/ and $1 >= 2.0) {
+ ds_warn
+"Source format is earlier than 2.0, switch compression to gzip";
+ $self->compression('gzip');
+ $self->repack(1) unless ($self->upstream_comp eq 'gzip');
+ }
+ close F;
+ } elsif (-d 'debian') {
+ ds_warn "Missing debian/source/format, switch compression to gzip";
+ $self->compression('gzip');
+ $self->repack(1) unless ($self->upstream_comp eq 'gzip');
+ } elsif ($self->upstream_type eq 'tar') {
+
+ # Uncompressed tar
+ if (!$self->upstream_comp) {
+ $self->repack(1);
+ }
+ }
+ # Set to default. Will be changed after setting do_repack
+ $self->compression('default')
+ unless ($self->compression);
+ return 1;
+ },
+ sub {
+ my ($self) = @_;
+ $self->{mode} ||= 'symlink';
+ },
+];
+
+sub setmode {
+ my ($self, $nv, $kname) = @_;
+ return unless ($nv);
+ if (defined $self->mode and $self->mode ne $kname) {
+ return (0, "--$self->{mode} and --$kname are mutually exclusive");
+ }
+ $self->mode($kname);
+}
+
+1;
diff --git a/lib/Devscripts/Output.pm b/lib/Devscripts/Output.pm
new file mode 100644
index 0000000..6ef2947
--- /dev/null
+++ b/lib/Devscripts/Output.pm
@@ -0,0 +1,83 @@
+package Devscripts::Output;
+
+use strict;
+use Exporter 'import';
+use File::Basename;
+use constant accept => qr/^y(?:es)?\s*$/i;
+use constant refuse => qr/^n(?:o)?\s*$/i;
+
+our @EXPORT = (
+ qw(ds_debug ds_extra_debug ds_verbose ds_warn ds_error
+ ds_die ds_msg who_called $progname $verbose
+ ds_prompt accept refuse $ds_yes)
+);
+
+# ACCESSORS
+our ($verbose, $die_on_error, $ds_yes) = (0, 1, 0);
+
+our $progname = basename($0);
+
+sub printwarn {
+ my ($msg, $w) = @_;
+ chomp $msg;
+ if ($w) {
+ print STDERR "$msg\n";
+ } else {
+ print "$msg\n";
+ }
+}
+
+sub ds_msg {
+ my $msg = $_[0];
+ printwarn("$progname: $msg", $_[1]);
+}
+
+sub ds_verbose {
+ my $msg = $_[0];
+ if ($verbose > 0) {
+ printwarn("$progname info: $msg", $_[1]);
+ }
+}
+
+sub who_called {
+ return '' unless ($verbose > 1);
+ my @out = caller(1);
+ return " [$out[0]: $out[2]]";
+}
+
+sub ds_warn {
+ my $msg = $_[0];
+ printwarn("$progname warn: $msg" . who_called, 1);
+}
+
+sub ds_debug {
+ my $msg = $_[0];
+ printwarn("$progname debug: $msg", $_[1]) if $verbose > 1;
+}
+
+sub ds_extra_debug {
+ my $msg = $_[0];
+ printwarn("$progname debug: $msg", $_[1]) if $verbose > 2;
+}
+
+*ds_die = \&ds_error;
+
+sub ds_error {
+ my $msg = $_[0];
+ $msg = "$progname error: $msg" . who_called;
+ if ($die_on_error) {
+ print STDERR "$msg\n";
+ exit 1;
+ }
+ printwarn($msg, 1);
+}
+
+sub ds_prompt {
+ return 'yes' if ($ds_yes > 0);
+ print STDERR shift;
+ my $s = <STDIN>;
+ chomp $s;
+ return $s;
+}
+
+1;
diff --git a/lib/Devscripts/PackageDeps.pm b/lib/Devscripts/PackageDeps.pm
new file mode 100644
index 0000000..8bad0d7
--- /dev/null
+++ b/lib/Devscripts/PackageDeps.pm
@@ -0,0 +1,307 @@
+# Based vaguely on the deprecated dpkg-perl package modules
+# Dpkg::Package::List and Dpkg::Package::Package.
+# This module creates an object which holds package names and dependencies
+# (just Depends and Pre-Depends).
+# It can also calculate the total set of subdependencies using the
+# fulldepends method.
+#
+# Copyright 2002 Julian Gilbey <jdg@debian.org>
+#
+# 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 2 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 <https://www.gnu.org/licenses/>.
+
+package Devscripts::PackageDeps;
+use strict;
+use Carp;
+use Dpkg::Control;
+use Dpkg::IPC;
+use FileHandle;
+require 5.006_000;
+
+# This reads in a package file list, such as /var/lib/dpkg/status,
+# and parses it. Using /var/lib/dpkg/status is deprecated in favor of
+# fromStatus().
+
+# Syntax: Devscripts::PackageDeps->new($filename)
+
+sub new ($$) {
+ my $this = shift;
+ my $class = ref($this) || $this;
+ my $filename = shift;
+
+ my $self = {};
+
+ if (!defined $filename) {
+ croak("requires filename as parameter");
+ }
+
+ bless($self, $class);
+
+ my $fh = FileHandle->new($filename, 'r');
+ unless (defined $fh) {
+ croak("Unable to load $filename: $!");
+ }
+ $self->parse($fh, $filename);
+ $fh->close or croak("Problems encountered reading $filename: $!");
+
+ return $self;
+}
+
+# This reads in dpkg's status information and parses it.
+
+# Syntax: Devscripts::PackageDeps->fromStatus()
+
+sub fromStatus ($) {
+ my $this = shift;
+ my $class = ref($this) || $this;
+
+ my $self = {};
+
+ bless($self, $class);
+
+ my $fh = FileHandle->new;
+ my $pid = spawn(
+ exec => ['dpkg', '--status'],
+ to_pipe => $fh
+ );
+ unless (defined $pid) {
+ croak("Unable to run 'dpkg --status': $!");
+ }
+
+ $self->parse($fh, 'dpkg --status');
+
+ wait_child($pid, cmdline => 'dpkg --status', nocheck => 1);
+
+ return $self;
+}
+
+# Internal functions
+
+my $multiarch;
+
+sub multiarch () {
+ if (!defined $multiarch) {
+ $multiarch
+ = (system('dpkg --assert-multi-arch >/dev/null 2>&1') >> 8) == 0;
+ }
+ return $multiarch;
+}
+
+sub parse ($$$) {
+ my $self = shift;
+ my $fh = shift;
+ my $filename = shift;
+
+ my $ctrl;
+ PACKAGE_ENTRY:
+ while (defined($ctrl = Dpkg::Control->new(type => CTRL_FILE_STATUS))
+ && $ctrl->parse($fh, $filename)) {
+
+ # So we've got a package
+ my $pkg = $ctrl->{Package};
+ my @deps = ();
+
+ if ($ctrl->{Status} =~ /^\S+\s+\S+\s+(\S+)$/) {
+ my $status = $1;
+ unless ($status eq 'installed' or $status eq 'unpacked') {
+ undef $ctrl;
+ next PACKAGE_ENTRY;
+ }
+ }
+
+ for my $dep (qw(Depends Pre-Depends)) {
+ if (exists $ctrl->{$dep}) {
+ my $value = $ctrl->{$dep};
+ $value =~ s/\([^)]+\)//g; # ignore versioning information
+ $value =~ tr/ \t//d; # remove spaces
+ my @dep_pkgs = split /,/, $value;
+ foreach my $dep_pkg (@dep_pkgs) {
+ my @dep_pkg_alts = split /\|/, $dep_pkg;
+ if (@dep_pkg_alts == 1) { push @deps, $dep_pkg_alts[0]; }
+ else { push @deps, \@dep_pkg_alts; }
+ }
+ }
+ }
+
+ $self->{$pkg} = \@deps;
+ if ($ctrl->{Architecture} ne 'all' && multiarch) {
+ my $arch = $ctrl->{Architecture};
+ @deps = map { "$_:$arch" } @deps;
+ $self->{"$pkg:$arch"} = \@deps;
+ }
+ undef $ctrl;
+ }
+}
+
+# Get direct dependency information for a specified package
+# Returns an array or array ref depending on context
+
+# Syntax: $obj->dependencies($package)
+
+sub dependencies ($$) {
+ my $self = shift;
+ my $pkg = shift;
+
+ if (!defined $pkg) {
+ croak("requires package as parameter");
+ }
+
+ if (!exists $self->{$pkg}) {
+ return undef;
+ }
+
+ return wantarray ? @{ $self->{$pkg} } : $self->{$pkg};
+}
+
+# Get full dependency information for a specified package or packages,
+# including the packages themselves.
+#
+# This only follows the first of sets of alternatives, and ignores
+# dependencies on packages which do not appear to exist.
+# Returns an array or array ref
+
+# Syntax: $obj->full_dependencies(@packages)
+
+sub full_dependencies ($@) {
+ my $self = shift;
+ my @toprocess = @_;
+ my %deps;
+
+ return wantarray ? () : [] unless @toprocess;
+
+ while (@toprocess) {
+ my $next = shift @toprocess;
+ $next = $$next[0] if ref $next;
+ # Already seen?
+ next if exists $deps{$next};
+ # Known package?
+ next unless exists $self->{$next};
+ # Mark it as a dependency
+ $deps{$next} = 1;
+ push @toprocess, @{ $self->{$next} };
+ }
+
+ return wantarray ? keys %deps : [keys %deps];
+}
+
+# Given a set of packages, find a minimal set with respect to the
+# pre-partial order of dependency.
+#
+# This is vaguely based on the dpkg-mindep script by
+# Bill Allombert <ballombe@debian.org>. It only follows direct
+# dependencies, and does not attempt to follow indirect dependencies.
+#
+# This respects the all packages in sets of alternatives.
+# Returns: (\@minimal_set, \%dependencies)
+# where the %dependencies hash is of the form
+# non-minimal package => depending package
+
+# Syntax: $obj->min_dependencies(@packages)
+
+sub min_dependencies ($@) {
+ my $self = shift;
+ my @pkgs = @_;
+ my @min_pkgs = ();
+ my %dep_pkgs = ();
+
+ return (\@min_pkgs, \%dep_pkgs) unless @pkgs;
+
+ # We create a directed graph: the %forward_deps hash records arrows
+ # pkg A depends on pkg B; the %reverse_deps hash records the
+ # reverse arrows
+ my %forward_deps;
+ my %reverse_deps;
+
+ # Initialise
+ foreach my $pkg (@pkgs) {
+ $forward_deps{$pkg} = {};
+ $reverse_deps{$pkg} = {};
+ }
+
+ foreach my $pkg (@pkgs) {
+ next unless exists $self->{$pkg};
+ my @pkg_deps = @{ $self->{$pkg} };
+ while (@pkg_deps) {
+ my $dep = shift @pkg_deps;
+ if (ref $dep) {
+ unshift @pkg_deps, @$dep;
+ next;
+ }
+ if (exists $forward_deps{$dep}) {
+ $forward_deps{$pkg}{$dep} = 1;
+ $reverse_deps{$dep}{$pkg} = 1;
+ }
+ }
+ }
+
+ # We start removing packages from the tree if they have no dependencies.
+ # Once we have no such packages left, we must have mutual or cyclic
+ # dependencies, so we pick a random one to remove and then start again.
+ # We continue this until there are no packages left in the graph.
+ PACKAGE:
+ while (scalar keys %forward_deps) {
+ foreach my $pkg (keys %forward_deps) {
+ if (scalar keys %{ $forward_deps{$pkg} } == 0) {
+ # Great, no dependencies!
+ if (scalar keys %{ $reverse_deps{$pkg} }) {
+ # This package is depended upon, so we can remove it
+ # with care
+ foreach my $dep_pkg (keys %{ $reverse_deps{$pkg} }) {
+ # take the first mentioned package for the
+ # recorded list of depended-upon packages
+ $dep_pkgs{$pkg} ||= $dep_pkg;
+ delete $forward_deps{$dep_pkg}{$pkg};
+ }
+ } else {
+ # This package is not depended upon, so it must
+ # go into our mindep list
+ push @min_pkgs, $pkg;
+ }
+ # Now remove this node
+ delete $forward_deps{$pkg};
+ delete $reverse_deps{$pkg};
+ next PACKAGE;
+ }
+ }
+
+ # Oh, we didn't find any package which didn't depend on any other.
+ # We'll pick a random one, then. At least *some* package must
+ # be depended upon in this situation; let's pick one of these.
+ foreach my $pkg (keys %forward_deps) {
+ next unless scalar keys %{ $reverse_deps{$pkg} } > 0;
+
+ foreach my $dep_pkg (keys %{ $forward_deps{$pkg} }) {
+ delete $reverse_deps{$dep_pkg}{$pkg};
+ }
+ foreach my $dep_pkg (keys %{ $reverse_deps{$pkg} }) {
+ # take the first mentioned package for the
+ # recorded list of depended-upon packages
+ $dep_pkgs{$pkg} ||= $dep_pkg;
+ delete $forward_deps{$dep_pkg}{$pkg};
+ }
+
+ # Now remove this node
+ delete $forward_deps{$pkg};
+ delete $reverse_deps{$pkg};
+ # And onto the next package
+ goto PACKAGE;
+ }
+
+ # Ouch! We shouldn't ever get here
+ croak("Couldn't determine mindeps; this can't happen!");
+ }
+
+ return (\@min_pkgs, \%dep_pkgs);
+}
+
+1;
diff --git a/lib/Devscripts/Packages.pm b/lib/Devscripts/Packages.pm
new file mode 100644
index 0000000..75acb45
--- /dev/null
+++ b/lib/Devscripts/Packages.pm
@@ -0,0 +1,313 @@
+#! /usr/bin/perl
+
+# Copyright Bill Allombert <ballombe@debian.org> 2001.
+# Modifications copyright 2002 Julian Gilbey <jdg@debian.org>
+
+# 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 2 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 <https://www.gnu.org/licenses/>.
+
+package Devscripts::Packages;
+
+use strict;
+use warnings;
+use Carp;
+use Dpkg::Control;
+use Dpkg::IPC;
+use FileHandle;
+
+BEGIN {
+ use Exporter ();
+ use vars qw(@EXPORT @ISA %EXPORT_TAGS);
+ @EXPORT
+ = qw(PackagesToFiles FilesToPackages PackagesMatch InstalledPackages);
+ @ISA = qw(Exporter);
+ %EXPORT_TAGS = ();
+}
+
+=head1 NAME
+
+Devscript::Packages - Interface to the dpkg package database
+
+=head1 SYNOPSIS
+
+use Devscript::Packages;
+
+@files=PackagesToFiles(@packages);
+
+@packages=FilesToPackages(@files);
+
+@packages=PackagesMatch($regexp);
+
+$packages_hashref=InstalledPackages($sources);
+
+=head1 DESCRIPTION
+
+
+PackagesToFiles: Return a list of files contained in a list of packages.
+
+FilesToPackages: Return a list of packages containing at least
+one file in a list of files, taking care to handle diversions correctly.
+
+PackagesMatch: list of packages whose status match regexp.
+
+InstalledPackages: ref to hash with keys being installed packages
+(status = install ok installed). If $sources is true, then include
+the corresponding source packages as well in the list.
+
+=cut
+
+my $multiarch;
+
+sub multiarch () {
+ if (!defined $multiarch) {
+ $multiarch
+ = (system('dpkg --assert-multi-arch >/dev/null 2>&1') >> 8) == 0;
+ }
+ return $multiarch;
+}
+
+# input: a list of packages names.
+# output: list of files they contain.
+
+sub PackagesToFiles (@) {
+ return () if @_ == 0;
+
+ my %files = ();
+
+ # We fork and use an exec, so that we don't have to worry how long an
+ # input string the shell can handle.
+
+ my $pid;
+ my $sleep_count = 0;
+ do {
+ $pid = open(DPKG, "-|");
+ unless (defined $pid) {
+ carp("cannot fork: $!");
+ croak("bailing out") if $sleep_count++ > 6;
+ sleep 10;
+ }
+ } until defined $pid;
+
+ if ($pid) { # parent
+ while (<DPKG>) {
+ chomp;
+ next if /^package diverts others to: / or -d $_;
+ $files{$_} = 1;
+ }
+ close DPKG or croak("dpkg -L failed: $!");
+ } else { # child
+ # We must use C locale, else diversion messages may be translated.
+ $ENV{'LC_ALL'} = 'C';
+ exec('dpkg', '-L', @_)
+ or croak("can't exec dpkg -L: $!");
+ }
+
+ return keys %files;
+}
+
+# This basically runs a dpkg -S with a few bells and whistles
+#
+# input: a list of files.
+# output: list of packages they belong to.
+
+sub FilesToPackages (@) {
+ return () if @_ == 0;
+
+ # We fork and use an exec, so that we don't have to worry how long an
+ # input string the shell can handle.
+
+ my @dpkg_out;
+ my $pid;
+ my $sleep_count = 0;
+ do {
+ $pid = open(DPKG, "-|");
+ unless (defined $pid) {
+ carp("cannot fork: $!");
+ croak("bailing out") if $sleep_count++ > 6;
+ sleep 10;
+ }
+ } until defined $pid;
+
+ if ($pid) { # parent
+ while (<DPKG>) {
+ # We'll process it later
+ chomp;
+ push @dpkg_out, $_;
+ }
+ if (!close DPKG) {
+ # exit status of 1 just indicates unrecognised files
+ if ($? & 0xff || $? >> 8 != 1) {
+ carp( "warning: dpkg -S exited with signal "
+ . ($? & 0xff)
+ . " and status "
+ . ($? >> 8));
+ }
+ }
+ } else { # child
+ # We must use C locale, else diversion messages may be translated.
+ $ENV{'LC_ALL'} = 'C';
+ open STDERR, '>& STDOUT'; # Capture STDERR as well
+ exec('dpkg', '-S', @_)
+ or croak("can't exec dpkg -S: $!");
+ }
+
+ my %packages = ();
+ foreach my $curfile (@_) {
+ my $pkgfrom;
+ foreach my $line (@dpkg_out) {
+ # We want to handle diversions nicely.
+ # Ignore local diversions
+ if ($line =~ /^local diversion from: /) {
+ # Do nothing
+ } elsif ($line =~ /^local diversion to: (.+)$/) {
+ if ($curfile eq $1) {
+ last;
+ }
+ } elsif ($line =~ /^diversion by (\S+) from: (.+)$/) {
+ if ($curfile eq $2) {
+ # So the file we're looking has been diverted
+ $pkgfrom = $1;
+ }
+ } elsif ($line =~ /^diversion by (\S+) to: (.+)$/) {
+ if ($curfile eq $2) {
+ # So the file we're looking is a diverted file
+ # We shouldn't see it again
+ $packages{$1} = 1;
+ last;
+ }
+ } elsif ($line =~ /^dpkg: \Q$curfile\E not found\.$/) {
+ last;
+ } elsif ($line
+ =~ /^dpkg-query: no path found matching pattern \Q$curfile\E\.$/
+ ) {
+ last;
+ } elsif ($line =~ /^(.*): \Q$curfile\E$/) {
+ my @pkgs = split /,\s+/, $1;
+ if (@pkgs == 1 || !grep /:/, @pkgs) {
+ # Only one package, or all Multi-Arch packages
+ map { $packages{$_} = 1 } @pkgs;
+ } else {
+ # We've got a file which has been diverted by some package
+ # or is Multi-Arch and so is listed in two packages. If it
+ # was diverted, the *diverting* package is the one with the
+ # file that was actually used.
+ my $found = 0;
+ foreach my $pkg (@pkgs) {
+ if ($pkg eq $pkgfrom) {
+ $packages{$pkgfrom} = 1;
+ $found = 1;
+ last;
+ }
+ }
+ if (!$found) {
+ carp(
+"Something wicked happened to the output of dpkg -S $curfile"
+ );
+ }
+ }
+ # Prepare for the next round
+ last;
+ }
+ }
+ }
+
+ return keys %packages;
+}
+
+# Return a list of packages whose status entries match a given pattern
+
+sub PackagesMatch ($) {
+ my $match = $_[0];
+ my @matches = ();
+
+ my $fout = FileHandle->new;
+ my $pid = spawn(
+ exec => ['dpkg', '--status'],
+ to_pipe => $fout
+ );
+ unless (defined $pid) {
+ croak("Unable to run \"dpkg --status\": $!");
+ }
+
+ my $ctrl;
+ while (defined($ctrl = Dpkg::Control->new())
+ && $ctrl->parse($fout, 'dpkg --status')) {
+ if ("$ctrl" =~ m/$match/m) {
+ my $package = $ctrl->{Package};
+ if ($ctrl->{Architecture} ne 'all' && multiarch) {
+ $package .= ":$ctrl->{Architecture}";
+ }
+ push @matches, $package;
+ }
+ undef $ctrl;
+ }
+
+ wait_child($pid, cmdline => 'dpkg --status', nocheck => 1);
+ return @matches;
+}
+
+# Which packages are installed (Package and Source)?
+
+sub InstalledPackages ($) {
+ my $source = $_[0];
+
+ my $fout = FileHandle->new;
+ my $pid = spawn(
+ exec => ['dpkg', '--status'],
+ to_pipe => $fout
+ );
+ unless (defined $pid) {
+ croak("Unable to run \"dpkg --status\": $!");
+ }
+
+ my $ctrl;
+ my %matches;
+ while (defined($ctrl = Dpkg::Control->new(type => CTRL_FILE_STATUS))
+ && $ctrl->parse($fout, 'dpkg --status')) {
+ if ($ctrl->{Status} !~ /^install\s+ok\s+installed$/) {
+ next;
+ }
+ if ($source) {
+ if (exists $ctrl->{Source}) {
+ $matches{ $ctrl->{Source} } = 1;
+ }
+ }
+ if (exists $ctrl->{Package}) {
+ $matches{ $ctrl->{Package} } = 1;
+ if ($ctrl->{Architecture} ne 'all' && multiarch) {
+ $matches{"$ctrl->{Package}:$ctrl->{Architecture}"} = 1;
+ }
+ }
+ undef $ctrl;
+ }
+
+ wait_child($pid, cmdline => 'dpkg --status', nocheck => 1);
+
+ return \%matches;
+}
+
+1;
+
+=head1 AUTHOR
+
+Bill Allombert <ballombe@debian.org>
+
+=head1 COPYING
+
+Copyright 2001 Bill Allombert <ballombe@debian.org>
+Modifications copyright 2002 Julian Gilbey <jdg@debian.org>
+dpkg-depcheck is free software, covered by the GNU General Public License, and
+you are welcome to change it and/or distribute copies of it under
+certain conditions. There is absolutely no warranty for dpkg-depcheck.
+
+=cut
diff --git a/lib/Devscripts/Salsa.pm b/lib/Devscripts/Salsa.pm
new file mode 100755
index 0000000..d1dd92d
--- /dev/null
+++ b/lib/Devscripts/Salsa.pm
@@ -0,0 +1,427 @@
+package Devscripts::Salsa;
+
+=head1 NAME
+
+Devscripts::Salsa - salsa(1) base object
+
+=head1 SYNOPSIS
+
+ use Devscripts::Salsa;
+ exit Devscripts::Salsa->new->run
+
+=head1 DESCRIPTION
+
+Devscripts::Salsa provides salsa(1) command launcher and some common utilities
+methods.
+
+=cut
+
+use strict;
+
+use Devscripts::Output;
+use Devscripts::Salsa::Config;
+
+BEGIN {
+ eval "use GitLab::API::v4;use GitLab::API::v4::Constants qw(:all)";
+ if ($@) {
+ print STDERR "You must install GitLab::API::v4\n";
+ exit 1;
+ }
+}
+use Moo;
+use File::Basename;
+use File::Path qw(make_path);
+
+# Command aliases
+use constant cmd_aliases => {
+ # Alias => Filename -> ./lib/Devscripts/Salsa/*.pm
+ # Preferred terminology
+ check_projects => 'check_repo',
+ create_project => 'create_repo',
+ delete_project => 'del_repo',
+ delete_user => 'del_user',
+ list_projects => 'list_repos',
+ list_users => 'group',
+ search_groups => 'search_group',
+ search_projects => 'search_project',
+ search_users => 'search_user',
+ update_projects => 'update_repo',
+
+ # Catch possible typo (As able to-do multiple items at once)
+ list_user => 'group',
+ check_project => 'check_repo',
+ list_project => 'list_repos',
+ update_project => 'update_repo',
+
+ # Abbreviation
+ co => 'checkout',
+ ls => 'list_repos',
+ mr => 'merge_request',
+ mrs => 'merge_requests',
+ schedule => 'pipeline_schedule',
+ schedules => 'pipeline_schedules',
+
+ # Legacy
+ search => 'search_project',
+ search_repo => 'search_project',
+};
+
+=head1 ACCESSORS
+
+=over
+
+=item B<config> : Devscripts::Salsa::Config object (parsed)
+
+=cut
+
+has config => (
+ is => 'rw',
+ default => sub { Devscripts::Salsa::Config->new->parse },
+);
+
+=item B<cache> : Devscripts::JSONCache object
+
+=cut
+
+# File cache to avoid polling GitLab too much
+# (used to store ids, paths and names)
+has _cache => (
+ is => 'rw',
+ lazy => 1,
+ default => sub {
+ return {} unless ($_[0]->config->cache_file);
+ my %h;
+ eval {
+ my ($cache_file, $cache_dir) = fileparse $_[0]->config->cache_file;
+ if (!-d $cache_dir) {
+ make_path $cache_dir;
+ }
+ require Devscripts::JSONCache;
+ tie %h, 'Devscripts::JSONCache', $_[0]->config->cache_file;
+ ds_debug "Cache opened";
+ };
+ if ($@) {
+ ds_verbose "Unable to create cache object: $@";
+ return {};
+ }
+ return \%h;
+ },
+);
+has cache => (
+ is => 'rw',
+ lazy => 1,
+ default => sub {
+ $_[0]->_cache->{ $_[0]->config->api_url } //= {};
+ return $_[0]->_cache->{ $_[0]->config->api_url };
+ },
+);
+
+# In memory cache (used to avoid querying the project id twice when using
+# update_safe
+has projectCache => (
+ is => 'rw',
+ default => sub { {} },
+);
+
+=item B<api>: GitLab::API::v4 object
+
+=cut
+
+has api => (
+ is => 'rw',
+ lazy => 1,
+ default => sub {
+ my $r = GitLab::API::v4->new(
+ url => $_[0]->config->api_url,
+ (
+ $_[0]->config->private_token
+ ? (private_token => $_[0]->config->private_token)
+ : ()
+ ),
+ );
+ $r or ds_die "Unable to create GitLab::API::v4 object";
+ return $r;
+ },
+);
+
+=item User or group in use
+
+=over
+
+=item B<username>
+
+=item B<user_id>
+
+=item B<group_id>
+
+=item B<group_path>
+
+=back
+
+=cut
+
+# Accessors that resolve names, ids or paths
+has username => (
+ is => 'rw',
+ lazy => 1,
+ default => sub { $_[0]->id2username });
+
+has user_id => (
+ is => 'rw',
+ lazy => 1,
+ default => sub {
+ $_[0]->config->user_id || $_[0]->username2id;
+ },
+);
+
+has group_id => (
+ is => 'rw',
+ lazy => 1,
+ default => sub { $_[0]->config->group_id || $_[0]->group2id },
+);
+
+has group_path => (
+ is => 'rw',
+ lazy => 1,
+ default => sub {
+ my ($self) = @_;
+ return undef unless ($self->group_id);
+ return $self->cache->{group_path}->{ $self->{group_id} }
+ if $self->cache->{group_path}->{ $self->{group_id} };
+ return $self->{group_path} if ($self->{group_path}); # Set if --group
+ eval {
+ $self->{group_path}
+ = $self->api->group_without_projects($self->group_id)
+ ->{full_path};
+ $self->cache->{group_path}->{ $self->{group_id} }
+ = $self->{group_path};
+ };
+ if ($@) {
+ ds_verbose $@;
+ ds_warn "Unexistent group " . $self->group_id;
+ return undef;
+ }
+ return $self->{group_path};
+ },
+);
+
+=back
+
+=head1 METHODS
+
+=over
+
+=item B<run>: main method, load and run command and return Unix result code.
+
+=cut
+
+sub run {
+ my ($self, $args) = @_;
+ binmode STDOUT, ':utf8';
+
+ # Check group or user id
+ my $command = $self->config->command;
+ if (my $tmp = cmd_aliases->{$command}) {
+ $command = $tmp;
+ }
+ eval { with "Devscripts::Salsa::$command" };
+ if ($@) {
+ ds_verbose $@;
+ ds_die "Unknown command $command";
+ return 1;
+ }
+ return $self->$command(@ARGV);
+}
+
+=back
+
+=head2 Utilities
+
+=over
+
+=item B<levels_name>, B<levels_code>: convert strings to GitLab level codes
+(owner, maintainer, developer, reporter and guest)
+
+=cut
+
+sub levels_name {
+ my $res = {
+
+ # needs GitLab::API::v4::Constants 0.11
+ # no_access => $GITLAB_ACCESS_LEVEL_NO_ACCESS,
+ guest => $GITLAB_ACCESS_LEVEL_GUEST,
+ reporter => $GITLAB_ACCESS_LEVEL_REPORTER,
+ developer => $GITLAB_ACCESS_LEVEL_DEVELOPER,
+ maintainer => $GITLAB_ACCESS_LEVEL_MASTER,
+ owner => $GITLAB_ACCESS_LEVEL_OWNER,
+ }->{ $_[1] };
+ ds_die "Unknown access level '$_[1]'" unless ($res);
+ return $res;
+}
+
+sub levels_code {
+ return {
+ $GITLAB_ACCESS_LEVEL_GUEST => 'guest',
+ $GITLAB_ACCESS_LEVEL_REPORTER => 'reporter',
+ $GITLAB_ACCESS_LEVEL_DEVELOPER => 'developer',
+ $GITLAB_ACCESS_LEVEL_MASTER => 'maintainer',
+ $GITLAB_ACCESS_LEVEL_OWNER => 'owner',
+ }->{ $_[1] };
+}
+
+=item B<username2id>, B<id2username>: convert username to an id an reverse
+
+=cut
+
+sub username2id {
+ my ($self, $user) = @_;
+ $user ||= $self->config->user || $self->api->current_user->{id};
+ unless ($user) {
+ return ds_warn "Token seems invalid";
+ return 1;
+ }
+ unless ($user =~ /^\d+$/) {
+ return $self->cache->{user_id}->{$user}
+ if $self->cache->{user_id}->{$user};
+ my $users = $self->api->users({ username => $user });
+ return ds_die "Username '$user' not found"
+ unless ($users and @$users);
+ ds_verbose "$user id is $users->[0]->{id}";
+ $self->cache->{user_id}->{$user} = $users->[0]->{id};
+ return $users->[0]->{id};
+ }
+ return $user;
+}
+
+sub id2username {
+ my ($self, $id) = @_;
+ $id ||= $self->config->user_id || $self->api->current_user->{id};
+ return $self->cache->{user}->{$id} if $self->cache->{user}->{$id};
+ my $res = eval { $self->api->user($id)->{username} };
+ if ($@) {
+ ds_verbose $@;
+ return ds_die "$id not found";
+ }
+ ds_verbose "$id is $res";
+ $self->cache->{user}->{$id} = $res;
+ return $res;
+}
+
+=item B<group2id>: convert group name to id
+
+=cut
+
+sub group2id {
+ my ($self, $name) = @_;
+ $name ||= $self->config->group;
+ return unless $name;
+ if ($self->cache->{group_id}->{$name}) {
+ $self->group_path($self->cache->{group_id}->{$name}->{path});
+ return $self->group_id($self->cache->{group_id}->{$name}->{id});
+ }
+ my $groups = $self->api->group_without_projects($name);
+ if ($groups) {
+ $groups = [$groups];
+ } else {
+ $self->api->groups({ search => $name });
+ }
+ return ds_die "No group found" unless ($groups and @$groups);
+ if (scalar @$groups > 1) {
+ ds_warn "More than one group found:";
+ foreach (@$groups) {
+ print <<END;
+Id : $_->{id}
+Name : $_->{name}
+Full name: $_->{full_name}
+Full path: $_->{full_path}
+
+END
+ }
+ return ds_die "Set the chosen group id using --group-id.";
+ }
+ ds_verbose "$name id is $groups->[0]->{id}";
+ $self->cache->{group_id}->{$name}->{path}
+ = $self->group_path($groups->[0]->{full_path});
+ $self->cache->{group_id}->{$name}->{id} = $groups->[0]->{id};
+ return $self->group_id($groups->[0]->{id});
+}
+
+=item B<project2id>: get id of a project.
+
+=cut
+
+sub project2id {
+ my ($self, $project) = @_;
+ return $project if ($project =~ /^\d+$/);
+ my $res;
+ $project = $self->project2path($project);
+ if ($self->projectCache->{$project}) {
+ ds_debug "use cached id for $project";
+ return $self->projectCache->{$project};
+ }
+ unless ($project =~ /^\d+$/) {
+ eval { $res = $self->api->project($project)->{id}; };
+ if ($@) {
+ ds_debug $@;
+ ds_warn "Project $project not found";
+ return undef;
+ }
+ }
+ ds_verbose "$project id is $res";
+ $self->projectCache->{$project} = $res;
+ return $res;
+}
+
+=item B<project2path>: get full path of a project
+
+=cut
+
+sub project2path {
+ my ($self, $project) = @_;
+ return $project if ($project =~ m#/#);
+ my $path = $self->main_path;
+ return undef unless ($path);
+ ds_verbose "Project $project => $path/$project";
+ return "$path/$project";
+}
+
+=item B<main_path>: build path using given group or user
+
+=cut
+
+sub main_path {
+ my ($self) = @_;
+ my $path;
+ if ($self->config->path) {
+ $path = $self->config->path;
+ } elsif (my $tmp = $self->group_path) {
+ $path = $tmp;
+ } elsif ($self->user_id) {
+ $path = $self->username;
+ } else {
+ ds_warn "Unable to determine project path";
+ return undef;
+ }
+ return $path;
+}
+
+# GitLab::API::v4 does not permit to call /groups/:id with parameters.
+# It takes too much time for the "debian" group, since it returns the list of
+# all projects together with all the details of the projects
+sub GitLab::API::v4::group_without_projects {
+ my $self = shift;
+ return $self->_call_rest_client('GET', 'groups/:group_id', [@_],
+ { query => { with_custom_attributes => 0, with_projects => 0 } });
+}
+
+1;
+
+=back
+
+=head1 AUTHOR
+
+Xavier Guimard E<lt>yadd@debian.orgE<gt>
+
+=head1 COPYRIGHT AND LICENSE
+
+Copyright 2018, Xavier Guimard E<lt>yadd@debian.orgE<gt>
diff --git a/lib/Devscripts/Salsa/Config.pm b/lib/Devscripts/Salsa/Config.pm
new file mode 100755
index 0000000..67b9624
--- /dev/null
+++ b/lib/Devscripts/Salsa/Config.pm
@@ -0,0 +1,524 @@
+# Salsa configuration (inherits from Devscripts::Config)
+package Devscripts::Salsa::Config;
+
+use strict;
+use Devscripts::Output;
+use Moo;
+
+extends 'Devscripts::Config';
+
+# Declare accessors for each option
+# Source : ./lib/Devscripts/Salsa/Config.pm:use constant keys
+# command & private_token
+# Skipping: info
+# Note : [Salsa = GitLab] jobs = builds, info = prompt, token = private_token
+foreach (qw(
+ command private_token
+ chdir cache_file no_cache path yes no_fail verbose debug
+ user user_id group group_id token token_file
+ all all_archived archived skip skip_file no_skip
+ analytics auto_devops container environments feature_flags forks
+ infrastructure issues jobs lfs monitor mr packages pages releases
+ repo request_access requirements security_compliance service_desk snippets
+ wiki
+ avatar_path desc desc_pattern
+ email disable_email email_recipient
+ irc_channel
+ irker disable_irker irker_host irker_port
+ kgb disable_kgb kgb_options
+ tagpending disable_tagpending
+ rename_head source_branch dest_branch
+ enable_remove_branch disable_remove_branch
+ build_timeout ci_config_path
+ schedule_desc schedule_ref schedule_cron schedule_tz schedule_enable
+ schedule_disable schedule_run schedule_delete
+ mr_allow_squash mr_desc mr_dst_branch mr_dst_project
+ mr_remove_source_branch mr_src_branch mr_src_project mr_title
+ api_url git_server_url irker_server_url kgb_server_url
+ tagpending_server_url
+ )
+) {
+ has $_ => (is => 'rw');
+}
+
+my $cacheDir;
+
+our @kgbOpt = qw(
+ push_events issues_events confidential_issues_events
+ confidential_comments_events merge_requests_events tag_push_events
+ note_events job_events pipeline_events wiki_page_events
+ confidential_note_events enable_ssl_verification
+);
+
+BEGIN {
+ $cacheDir = $ENV{XDG_CACHE_HOME} || $ENV{HOME} . '/.cache';
+}
+
+# Options
+use constant keys => [
+ # General salsa
+ [
+ 'C|chdir=s', undef,
+ sub { return (chdir($_[1]) ? 1 : (0, "$_[1] doesn't exist")) }
+ ],
+ [
+ 'cache-file',
+ 'SALSA_CACHE_FILE',
+ sub {
+ $_[0]->cache_file($_[1] ? $_[1] : undef);
+ },
+ "$cacheDir/salsa.json"
+ ],
+ [
+ 'no-cache',
+ 'SALSA_NO_CACHE',
+ sub {
+ $_[0]->cache_file(undef)
+ if ($_[1] !~ /^(?:no|0+)$/i);
+ return 1;
+ }
+ ],
+ [
+ 'path=s',
+ 'SALSA_REPO_PATH',
+ sub {
+ $_ = $_[1];
+ s#/*(.*)/*#$1#;
+ $_[0]->path($_);
+ return /^[\w\d\-]+$/ ? 1 : (0, "Bad path $_");
+ }
+ ],
+
+ # Responses
+ ['yes!', 'SALSA_YES', sub { info(1, "SALSA_YES", @_) }],
+ ['no-fail', 'SALSA_NO_FAIL', 'bool'],
+
+ # Output
+ ['verbose!', 'SALSA_VERBOSE', sub { $verbose = 1 }],
+ ['debug', undef, sub { $verbose = 2 }],
+ ['info|i', 'SALSA_INFO', sub { info(-1, 'SALSA_INFO', @_) }],
+
+ # General GitLab
+ ['user=s', 'SALSA_USER', qr/^[\-\w]+$/],
+ ['user-id=s', 'SALSA_USER_ID', qr/^\d+$/],
+ ['group=s', 'SALSA_GROUP', qr/^[\/\-\w]+$/],
+ ['group-id=s', 'SALSA_GROUP_ID', qr/^\d+$/],
+ ['token', 'SALSA_TOKEN', sub { $_[0]->private_token($_[1]) }],
+ [
+ 'token-file',
+ 'SALSA_TOKEN_FILE',
+ sub {
+ my ($self, $v) = @_;
+ return (0, "Unable to open token file") unless (-r $v);
+ open F, $v;
+ my $s = join '', <F>;
+ close F;
+ if ($s
+ =~ m/^[^#]*(?:SALSA_(?:PRIVATE_)?TOKEN)\s*=\s*(["'])?([-\w]+)\1?$/m
+ ) {
+ $self->private_token($2);
+ return 1;
+ } else {
+ return (0, "No token found in file $v");
+ }
+ }
+ ],
+
+ # List/search
+ ['all'],
+ ['all-archived'],
+ ['archived!', 'SALSA_ARCHIVED', 'bool', 0],
+ ['skip=s', 'SALSA_SKIP', undef, sub { [] }],
+ [
+ 'skip-file=s',
+ 'SALSA_SKIP_FILE',
+ sub {
+ return 1 unless $_[1];
+ return (0, "Unable to read $_[1]") unless (-r $_[1]);
+ open my $fh, $_[1];
+ push @{ $_[0]->skip }, (map { chomp $_; ($_ ? $_ : ()) } <$fh>);
+ return 1;
+ }
+ ],
+ ['no-skip', undef, sub { $_[0]->skip([]); $_[0]->skip_file(undef); }],
+
+ # Features
+ [
+ 'analytics=s', 'SALSA_ENABLE_ANALYTICS',
+ qr/y(es)?|true|enabled?|private|no?|false|disabled?/
+ ],
+ [
+ 'auto-devops=s',
+ 'SALSA_ENABLE_AUTO_DEVOPS',
+ qr/y(es)?|true|enabled?|1|no?|false|disabled?|0/
+ ],
+ [
+ 'container=s', 'SALSA_ENABLE_CONTAINER',
+ qr/y(es)?|true|enabled?|private|no?|false|disabled?/
+ ],
+ [
+ 'environments=s',
+ 'SALSA_ENABLE_ENVIRONMENTS',
+ qr/y(es)?|true|enabled?|private|no?|false|disabled?/
+ ],
+ [
+ 'feature-flags=s',
+ 'SALSA_ENABLE_FEATURE_FLAGS',
+ qr/y(es)?|true|enabled?|private|no?|false|disabled?/
+ ],
+ [
+ 'forks=s', 'SALSA_ENABLE_FORKS',
+ qr/y(es)?|true|enabled?|private|no?|false|disabled?/
+ ],
+ [
+ 'infrastructure=s',
+ 'SALSA_ENABLE_INFRASTRUCTURE',
+ qr/y(es)?|true|enabled?|private|no?|false|disabled?/
+ ],
+ [
+ 'issues=s', 'SALSA_ENABLE_ISSUES',
+ qr/y(es)?|true|enabled?|private|no?|false|disabled?/
+ ],
+# Renamed terminology, kept for legacy: jobs == builds_access_level (ENABLE_JOBS -> ENABLE_BUILD)
+ [
+ 'jobs=s', 'SALSA_ENABLE_JOBS',
+ qr/y(es)?|true|enabled?|private|no?|false|disabled?/
+ ],
+ [
+ 'lfs=s', 'SALSA_ENABLE_LFS',
+ qr/y(es)?|true|enabled?|1|no?|false|disabled?|0/
+ ],
+ [
+ 'monitor=s', 'SALSA_ENABLE_MONITOR',
+ qr/y(es)?|true|enabled?|private|no?|false|disabled?/
+ ],
+ [
+ 'mr=s', 'SALSA_ENABLE_MR',
+ qr/y(es)?|true|enabled?|private|no?|false|disabled?/
+ ],
+ [
+ 'packages=s', 'SALSA_ENABLE_PACKAGES',
+ qr/y(es)?|true|enabled?|1|no?|false|disabled?|0/
+ ],
+ [
+ 'pages=s', 'SALSA_ENABLE_PAGES',
+ qr/y(es)?|true|enabled?|private|no?|false|disabled?/
+ ],
+ [
+ 'releases=s', 'SALSA_ENABLE_RELEASES',
+ qr/y(es)?|true|enabled?|private|no?|false|disabled?/
+ ],
+ [
+ 'repo=s', 'SALSA_ENABLE_REPO',
+ qr/y(es)?|true|enabled?|private|no?|false|disabled?/
+ ],
+ [
+ 'request-access=s',
+ 'SALSA_REQUEST_ACCESS',
+ qr/y(es)?|true|enabled?|1|no?|false|disabled?|0/
+ ],
+ [
+ 'requirements=s',
+ 'SALSA_ENABLE_REQUIREMENTS',
+ qr/y(es)?|true|enabled?|private|no?|false|disabled?/
+ ],
+ [
+ 'security-compliance=s',
+ 'SALSA_ENABLE_SECURITY_COMPLIANCE',
+ qr/y(es)?|true|enabled?|private|no?|false|disabled?/
+ ],
+ [
+ 'service-desk=s',
+ 'SALSA_ENABLE_SERVICE_DESK',
+ qr/y(es)?|true|enabled?|1|no?|false|disabled?|0/
+ ],
+ [
+ 'snippets=s', 'SALSA_ENABLE_SNIPPETS',
+ qr/y(es)?|true|enabled?|private|no?|false|disabled?/
+ ],
+ [
+ 'wiki=s', 'SALSA_ENABLE_WIKI',
+ qr/y(es)?|true|enabled?|private|no?|false|disabled?/
+ ],
+
+ # Branding
+ ['avatar-path=s', 'SALSA_AVATAR_PATH', undef],
+ ['desc!', 'SALSA_DESC', 'bool'],
+ ['desc-pattern=s', 'SALSA_DESC_PATTERN', qr/\w/, 'Debian package %p'],
+
+ # Notification
+ [
+ 'email!', undef,
+ sub { !$_[1] or $_[0]->enable('yes', 'email', 'disable_email'); }
+ ],
+ [
+ 'disable-email!', undef,
+ sub { !$_[1] or $_[0]->enable('no', 'email', 'disable_email'); }
+ ],
+ [
+ undef, 'SALSA_EMAIL',
+ sub { $_[0]->enable($_[1], 'email', 'disable_email'); }
+ ],
+ ['email-recipient=s', 'SALSA_EMAIL_RECIPIENTS', undef, sub { [] }],
+ ['irc-channel|irc=s', 'SALSA_IRC_CHANNEL', undef, sub { [] }],
+ [
+ 'irker!', undef,
+ sub { !$_[1] or $_[0]->enable('yes', 'irker', 'disable_irker'); }
+ ],
+ [
+ 'disable-irker!', undef,
+ sub { !$_[1] or $_[0]->enable('no', 'irker', 'disable_irker'); }
+ ],
+ [
+ undef, 'SALSA_IRKER',
+ sub { $_[0]->enable($_[1], 'irker', 'disable_irker'); }
+ ],
+ ['irker-host=s', 'SALSA_IRKER_HOST', undef, 'ruprecht.snow-crash.org'],
+ ['irker-port=s', 'SALSA_IRKER_PORT', qr/^\d*$/],
+ [
+ 'kgb!', undef,
+ sub { !$_[1] or $_[0]->enable('yes', 'kgb', 'disable_kgb'); }
+ ],
+ [
+ 'disable-kgb!', undef,
+ sub { !$_[1] or $_[0]->enable('no', 'kgb', 'disable_kgb'); }
+ ],
+ [undef, 'SALSA_KGB', sub { $_[0]->enable($_[1], 'kgb', 'disable_kgb'); }],
+ [
+ 'kgb-options=s',
+ 'SALSA_KGB_OPTIONS',
+ qr/\w/,
+ 'push_events,issues_events,merge_requests_events,tag_push_events,'
+ . 'note_events,pipeline_events,wiki_page_events,'
+ . 'enable_ssl_verification'
+ ],
+ [
+ 'tagpending!',
+ undef,
+ sub {
+ !$_[1]
+ or $_[0]->enable('yes', 'tagpending', 'disable_tagpending');
+ }
+ ],
+ [
+ 'disable-tagpending!',
+ undef,
+ sub {
+ !$_[1] or $_[0]->enable('no', 'tagpending', 'disable_tagpending');
+ }
+ ],
+ [
+ undef, 'SALSA_TAGPENDING',
+ sub { $_[0]->enable($_[1], 'tagpending', 'disable_tagpending'); }
+ ],
+
+ # Branch
+ ['rename-head!', 'SALSA_RENAME_HEAD', 'bool'],
+ ['source-branch=s', 'SALSA_SOURCE_BRANCH', undef, 'master'],
+ ['dest-branch=s', 'SALSA_DEST_BRANCH', undef, 'debian/master'],
+ [
+ 'enable-remove-source-branch!',
+ undef,
+ sub {
+ !$_[1]
+ or $_[0]
+ ->enable('yes', 'enable_remove_branch', 'disable_remove_branch');
+ }
+ ],
+ [
+ 'disable-remove-source-branch!',
+ undef,
+ sub {
+ !$_[1]
+ or $_[0]
+ ->enable('no', 'enable_remove_branch', 'disable_remove_branch');
+ }
+ ],
+ [
+ undef,
+ 'SALSA_REMOVE_SOURCE_BRANCH',
+ sub {
+ $_[0]
+ ->enable($_[1], 'enable_remove_branch', 'disable_remove_branch');
+ }
+ ],
+
+ # Merge requests
+ ['mr-allow-squash!', 'SALSA_MR_ALLOW_SQUASH', 'bool', 1],
+ ['mr-desc=s'],
+ ['mr-dst-branch=s', undef, undef, 'master'],
+ ['mr-dst-project=s'],
+ ['mr-remove-source-branch!', 'SALSA_MR_REMOVE_SOURCE_BRANCH', 'bool', 0],
+ ['mr-src-branch=s'],
+ ['mr-src-project=s'],
+ ['mr-title=s'],
+
+ # CI
+ ['build-timeout=s', 'SALSA_BUILD_TIMEOUT', qr/^\d+$/, '3600'],
+ ['ci-config-path=s', 'SALSA_CI_CONFIG_PATH', qr/\./],
+
+ # Pipeline schedules
+ ['schedule-desc=s', 'SALSA_SCHEDULE_DESC', qr/\w/],
+ ['schedule-ref=s', 'SALSA_SCHEDULE_REF'],
+ ['schedule-cron=s', 'SALSA_SCHEDULE_CRON'],
+ ['schedule-tz=s', 'SALSA_SCHEDULE_TZ'],
+ ['schedule-enable!', 'SALSA_SCHEDULE_ENABLE', 'bool'],
+ ['schedule-disable!', 'SALSA_SCHEDULE_DISABLE', 'bool'],
+ ['schedule-run!', 'SALSA_SCHEDULE_RUN', 'bool'],
+ ['schedule-delete!', 'SALSA_SCHEDULE_DELETE', 'bool'],
+
+ # Manage other GitLab instances
+ [
+ 'api-url=s', 'SALSA_API_URL',
+ qr#^https?://#, 'https://salsa.debian.org/api/v4'
+ ],
+ [
+ 'git-server-url=s', 'SALSA_GIT_SERVER_URL',
+ qr/^\S+\@\S+/, 'git@salsa.debian.org:'
+ ],
+ [
+ 'irker-server-url=s', 'SALSA_IRKER_SERVER_URL',
+ qr'^ircs?://', 'ircs://irc.oftc.net:6697/'
+ ],
+ [
+ 'kgb-server-url=s', 'SALSA_KGB_SERVER_URL',
+ qr'^https?://', 'https://kgb.debian.net/webhook/?channel='
+ ],
+ [
+ 'tagpending-server-url=s',
+ 'SALSA_TAGPENDING_SERVER_URL',
+ qr'^https?://',
+ 'https://webhook.salsa.debian.org/tagpending/'
+ ],
+];
+
+# Consistency rules
+use constant rules => [
+ # Reject unless token exists
+ sub {
+ return (1,
+"SALSA_TOKEN not set in configuration files. Some commands may fail"
+ ) unless ($_[0]->private_token);
+ },
+ # Get command
+ sub {
+ return (0, "No command given, aborting") unless (@ARGV);
+ $_[0]->command(shift @ARGV);
+ return (0, "Malformed command: " . $_[0]->command)
+ unless ($_[0]->command =~ /^[a-z_]+$/);
+ return 1;
+ },
+ sub {
+ if ( ($_[0]->group or $_[0]->group_id)
+ and ($_[0]->user_id or $_[0]->user)) {
+ ds_warn "Both --user-id and --group-id are set, ignore --group-id";
+ $_[0]->group(undef);
+ $_[0]->group_id(undef);
+ }
+ return 1;
+ },
+ sub {
+ if ($_[0]->group and $_[0]->group_id) {
+ ds_warn "Both --group-id and --group are set, ignore --group";
+ $_[0]->group(undef);
+ }
+ return 1;
+ },
+ sub {
+ if ($_[0]->user and $_[0]->user_id) {
+ ds_warn "Both --user-id and --user are set, ignore --user";
+ $_[0]->user(undef);
+ }
+ return 1;
+ },
+ sub {
+ if ($_[0]->email and not @{ $_[0]->email_recipient }) {
+ return (0, '--email-recipient needed with --email');
+ }
+ return 1;
+ },
+ sub {
+ if (@{ $_[0]->irc_channel }) {
+ foreach (@{ $_[0]->irc_channel }) {
+ if (/^#/) {
+ return (1,
+"# found in --irc-channel, assuming double hash is wanted"
+ );
+ }
+ }
+ if ($_[0]->irc_channel->[1] and $_[0]->kgb) {
+ return (0, "Only one IRC channel is accepted with --kgb");
+ }
+ }
+ return 1;
+ },
+ sub {
+ $_[0]->kgb_options([sort split ',\s*', $_[0]->kgb_options]);
+ my @err;
+ foreach my $o (@{ $_[0]->kgb_options }) {
+ unless (grep { $_ eq $o } @kgbOpt) {
+ push @err, $o;
+ }
+ }
+ return (0, "Unknown KGB options: " . join(', ', @err))
+ if @err;
+ return 1;
+ },
+];
+
+sub usage {
+# Source: ./scripts/salsa.pl:=head1 SYNOPSIS
+# ./lib/Devscripts/Salsa.pm:sub run -> $ ls ./lib/Devscripts/Salsa/*.pm
+ print <<END;
+usage: salsa <command> <parameters> <options>
+
+Most used commands for managing users and groups:
+ - add_user : Add a user to a group
+ - delete_user : Remove a user from a group
+ - search_groups : Search for a group using given string
+ - search_users : Search for a user using given string
+ - update_user : Update a user's role in a group
+ - whoami : Gives information on the token owner
+
+Most used commands for managing repositories:
+ - checkout : Clone a project's repository in current directory
+ - fork : Fork a project
+ - last_ci_status : Displays the last continuous integration result
+ - mr : Creates a merge request
+ - schedules : Lists current pipeline schedule items
+ - push_repo : Push local git repository to upstream repository
+ - search_projects: Search for a project using given string
+ - update_projects: Configure project(s) configuration
+ - update_safe : Shows differences before running update_projects
+
+See salsa(1) manpage for more.
+END
+}
+
+sub info {
+ my ($num, $key, undef, $nv) = @_;
+ $nv = (
+ $nv =~ /^yes|1$/ ? $num
+ : $nv =~ /^no|0$/i ? 0
+ : return (0, "Bad $key value"));
+ $ds_yes = $nv;
+}
+
+sub enable {
+ my ($self, $v, $en, $dis) = @_;
+ $v = lc($v);
+ if ($v eq 'ignore') {
+ $self->{$en} = $self->{$dis} = 0;
+ } elsif ($v eq 'yes') {
+ $self->{$en} = 1;
+ $self->{$dis} = 0;
+ } elsif ($v eq 'no') {
+ $self->{$en} = 0;
+ $self->{$dis} = 1;
+ } else {
+ return (0, "Bad value for SALSA_" . uc($en));
+ }
+ return 1;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/Hooks.pm b/lib/Devscripts/Salsa/Hooks.pm
new file mode 100644
index 0000000..1aa2a35
--- /dev/null
+++ b/lib/Devscripts/Salsa/Hooks.pm
@@ -0,0 +1,314 @@
+# Common hooks library
+package Devscripts::Salsa::Hooks;
+
+use strict;
+use Devscripts::Output;
+use Moo::Role;
+
+sub add_hooks {
+ my ($self, $repo_id, $repo) = @_;
+ if ( $self->config->kgb
+ or $self->config->disable_kgb
+ or $self->config->tagpending
+ or $self->config->disable_tagpending
+ or $self->config->irker
+ or $self->config->disable_irker
+ or $self->config->email
+ or $self->config->disable_email) {
+ my $hooks = $self->enabled_hooks($repo_id);
+ return 1 unless (defined $hooks);
+ # KGB hook (IRC)
+ if ($self->config->kgb or $self->config->disable_kgb) {
+ unless ($self->config->irc_channel->[0]
+ or $self->config->disable_kgb) {
+ ds_warn "--kgb needs --irc-channel";
+ return 1;
+ }
+ if ($self->config->irc_channel->[1]) {
+ ds_warn "KGB accepts only one --irc-channel value,";
+ }
+ if ($hooks->{kgb}) {
+ ds_warn "Deleting old kgb (was $hooks->{kgb}->{url})";
+ $self->api->delete_project_hook($repo_id, $hooks->{kgb}->{id});
+ }
+ if ($self->config->irc_channel->[0]
+ and not $self->config->disable_kgb) {
+ # TODO: if useful, add parameters for this options
+ eval {
+ $self->api->create_project_hook(
+ $repo_id,
+ {
+ url => $self->config->kgb_server_url
+ . $self->config->irc_channel->[0],
+ map { ($_ => 1) } @{ $self->config->kgb_options },
+ });
+ ds_verbose "KGB hook added to project $repo_id (channel: "
+ . $self->config->irc_channel->[0] . ')';
+ };
+ if ($@) {
+ ds_warn "Fail to add KGB hook: $@";
+ if (!$self->config->no_fail) {
+ ds_verbose "Use --no-fail to continue";
+ return 1;
+ }
+ }
+ }
+ }
+ # Irker hook (IRC)
+ if ($self->config->irker or $self->config->disable_irker) {
+ unless ($self->config->irc_channel->[0]
+ or $self->config->disable_irker) {
+ ds_warn "--irker needs --irc-channel";
+ return 1;
+ }
+ if ($hooks->{irker}) {
+ no warnings;
+ ds_warn
+"Deleting old irker (redirected to $hooks->{irker}->{recipients})";
+ $self->api->delete_project_service($repo_id, 'irker');
+ }
+ if ($self->config->irc_channel->[0]
+ and not $self->config->disable_irker) {
+ # TODO: if useful, add parameters for this options
+ my $ch = join(' ',
+ map { '#' . $_ } @{ $self->config->irc_channel });
+ $self->api->edit_project_service(
+ $repo_id, 'irker',
+ {
+ active => 1,
+ server_host => $self->config->irker_host,
+ (
+ $self->config->irker_port
+ ? (server_port => $self->config->irker_port)
+ : ()
+ ),
+ default_irc_uri => $self->config->irker_server_url,
+ recipients => $ch,
+ colorize_messages => 1,
+ });
+ ds_verbose
+ "Irker hook added to project $repo_id (channel: $ch)";
+ }
+ }
+ # email on push
+ if ($self->config->email or $self->config->disable_email) {
+ if ($hooks->{email}) {
+ no warnings;
+ ds_warn
+"Deleting old email-on-push (redirected to $hooks->{email}->{recipients})";
+ $self->api->delete_project_service($repo_id, 'emails-on-push');
+ }
+ if (@{ $self->config->email_recipient }
+ and not $self->config->disable_email) {
+ # TODO: if useful, add parameters for this options
+ $self->api->edit_project_service(
+ $repo_id,
+ 'emails-on-push',
+ {
+ recipients => join(' ',
+ map { my $a = $_; $a =~ s/%p/$repo/; $a }
+ @{ $self->config->email_recipient }),
+ });
+ no warnings;
+ ds_verbose
+ "Email-on-push hook added to project $repo_id (recipients: "
+ . join(' ', @{ $self->config->email_recipient }) . ')';
+ }
+ }
+ # Tagpending hook
+ if ($self->config->tagpending or $self->config->disable_tagpending) {
+ if ($hooks->{tagpending}) {
+ ds_warn
+ "Deleting old tagpending (was $hooks->{tagpending}->{url})";
+ $self->api->delete_project_hook($repo_id,
+ $hooks->{tagpending}->{id});
+ }
+ my $repo_name = $self->api->project($repo_id)->{name};
+ unless ($self->config->disable_tagpending) {
+ eval {
+ $self->api->create_project_hook(
+ $repo_id,
+ {
+ url => $self->config->tagpending_server_url
+ . $repo_name,
+ push_events => 1,
+ });
+ ds_verbose "Tagpending hook added to project $repo_id";
+ };
+ if ($@) {
+ ds_warn "Fail to add Tagpending hook: $@";
+ if (!$self->config->no_fail) {
+ ds_verbose "Use --no-fail to continue";
+ return 1;
+ }
+ }
+ }
+ }
+ }
+ return 0;
+}
+
+sub enabled_hooks {
+ my ($self, $repo_id) = @_;
+ my $hooks;
+ my $res = {};
+ if ( $self->config->kgb
+ or $self->config->disable_kgb
+ or $self->config->tagpending
+ or $self->config->disable_tagpending) {
+ $hooks = eval { $self->api->project_hooks($repo_id) };
+ if ($@) {
+ ds_warn "Unable to check hooks for project $repo_id";
+ return undef;
+ }
+ foreach my $h (@{$hooks}) {
+ $res->{kgb} = {
+ id => $h->{id},
+ url => $h->{url},
+ options => [grep { $h->{$_} and $h->{$_} eq 1 } keys %$h],
+ }
+ if $h->{url} =~ /\Q$self->{config}->{kgb_server_url}\E/;
+ $res->{tagpending} = {
+ id => $h->{id},
+ url => $h->{url},
+ }
+ if $h->{url} =~ /\Q$self->{config}->{tagpending_server_url}\E/;
+ }
+ }
+ if ( ($self->config->email or $self->config->disable_email)
+ and $_ = $self->api->project_service($repo_id, 'emails-on-push')
+ and $_->{active}) {
+ $res->{email} = $_->{properties};
+ }
+ if ( ($self->config->irker or $self->config->disable_irker)
+ and $_ = $self->api->project_service($repo_id, 'irker')
+ and $_->{active}) {
+ $res->{irker} = $_->{properties};
+ }
+ return $res;
+}
+
+sub _check_config {
+ my ($config, $key_name, $config_name, $can_be_private, $res_ref) = @_;
+ if (!$config) { return undef; }
+ for ($config) {
+ if ($can_be_private) {
+ if ($_ eq "private") {
+ push @$res_ref, $key_name => "private";
+ } elsif ($_ =~ qr/y(es)?|true|enabled?/) {
+ push @$res_ref, $key_name => "enabled";
+ } elsif ($_ =~ qr/no?|false|disabled?/) {
+ push @$res_ref, $key_name => "disabled";
+ } else {
+ print "error with SALSA_$config_name";
+ }
+ } else {
+ if ($_ =~ qr/y(es)?|true|enabled?/) {
+ push @$res_ref, $key_name => 1;
+ } elsif ($_ =~ qr/no?|false|disabled?/) {
+ push @$res_ref, $key_name => 0;
+ } else {
+ print "error with SALSA_$config_name";
+ }
+ }
+ }
+}
+
+sub desc {
+ my ($self, $repo) = @_;
+ my @res = ();
+ if ($self->config->desc) {
+ my $str = $self->config->desc_pattern;
+ $str =~ s/%P/$repo/g;
+ $repo =~ s#.*/##;
+ $str =~ s/%p/$repo/g;
+ push @res, description => $str;
+ }
+ if ($self->config->build_timeout) {
+ push @res, build_timeout => $self->config->build_timeout;
+ }
+ if ($self->config->ci_config_path) {
+ push @res, ci_config_path => $self->config->ci_config_path;
+ }
+
+ # Parameter: config value, key name, config name, has private
+ _check_config($self->config->analytics,
+ "analytics_access_level", "ENABLE_ANALYTICS", 1, \@res);
+ _check_config($self->config->auto_devops,
+ "auto_devops_enabled", "ENABLE_AUTO_DEVOPS", 0, \@res);
+ _check_config(
+ $self->config->container,
+ "container_registry_access_level",
+ "ENABLE_CONTAINER", 1, \@res
+ );
+ _check_config($self->config->environments,
+ "environments_access_level", "ENABLE_ENVIRONMENTS", 1, \@res);
+ _check_config($self->config->feature_flags,
+ "feature_flags_access_level", "ENABLE_FEATURE_FLAGS", 1, \@res);
+ _check_config($self->config->forks, "forking_access_level",
+ "ENABLE_FORKS", 1, \@res);
+ _check_config($self->config->infrastructure,
+ "infrastructure_access_level", "ENABLE_INFRASTRUCTURE", 1, \@res);
+ _check_config($self->config->issues, "issues_access_level",
+ "ENABLE_ISSUES", 1, \@res);
+# Renamed terminology, kept for legacy: jobs == builds_access_level (ENABLE_JOBS -> ENABLE_BUILD)
+ _check_config($self->config->jobs, "builds_access_level", "ENABLE_JOBS",
+ 1, \@res);
+ _check_config($self->config->lfs, "lfs_enabled", "ENABLE_LFS", 0, \@res);
+ _check_config($self->config->mr, "merge_requests_access_level",
+ "ENABLE_MR", 1, \@res);
+ _check_config($self->config->monitor,
+ "monitor_access_level", "ENABLE_MONITOR", 1, \@res);
+ _check_config($self->config->packages,
+ "packages_enabled", "ENABLE_PACKAGES", 0, \@res);
+ _check_config($self->config->pages, "pages_access_level", "ENABLE_PAGES",
+ 1, \@res);
+ _check_config($self->config->releases,
+ "releases_access_level", "ENABLE_RELEASES", 1, \@res);
+ _check_config(
+ $self->config->disable_remove_branch,
+ "remove_source_branch_after_merge",
+ "REMOVE_SOURCE_BRANCH", 0, \@res
+ );
+ _check_config($self->config->repo, "repository_access_level",
+ "ENABLE_REPO", 1, \@res);
+ _check_config($self->config->request_access,
+ "request_access_enabled", "REQUEST_ACCESS", 0, \@res);
+ _check_config($self->config->requirements,
+ "requirements_access_level", "ENABLE_REQUIREMENTS", 1, \@res);
+ _check_config(
+ $self->config->security_compliance,
+ "security_and_compliance_access_level",
+ "ENABLE_SECURITY_COMPLIANCE", 1, \@res
+ );
+ _check_config($self->config->service_desk,
+ "service_desk_enabled", "ENABLE_SERVICE_DESK", 0, \@res);
+ _check_config($self->config->snippets,
+ "snippets_access_level", "ENABLE_SNIPPETS", 1, \@res);
+ _check_config($self->config->wiki, "wiki_access_level", "ENABLE_WIKI", 1,
+ \@res);
+
+ return @res;
+}
+
+sub desc_multipart {
+ my ($self, $repo) = @_;
+ my @res = ();
+ if ($self->config->avatar_path) {
+ my $str = $self->config->avatar_path;
+ $str =~ s/%p/$repo/g;
+ unless (-r $str) {
+ ds_warn "Unable to find: $str";
+ unless ($self->config->no_fail) {
+ ds_verbose "Use --no-fail to continue";
+ exit 1;
+ }
+ } else {
+ # avatar_path (salsa) -> avatar (GitLab API)
+ push @res, avatar => $str;
+ }
+ }
+ return @res;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/Repo.pm b/lib/Devscripts/Salsa/Repo.pm
new file mode 100755
index 0000000..0370810
--- /dev/null
+++ b/lib/Devscripts/Salsa/Repo.pm
@@ -0,0 +1,73 @@
+# Common method to get projects
+package Devscripts::Salsa::Repo;
+
+use strict;
+use Devscripts::Output;
+use Moo::Role;
+
+with "Devscripts::Salsa::Hooks";
+
+sub get_repo {
+ my ($self, $prompt, @reponames) = @_;
+ my @repos;
+ if (($self->config->all or $self->config->all_archived)
+ and @reponames == 0) {
+ ds_debug "--all is set";
+ my $options = {};
+ $options->{order_by} = 'name';
+ $options->{sort} = 'asc';
+ $options->{archived} = 'false' if not $self->config->all_archived;
+ my $projects;
+ # This rule disallow trying to configure all "Debian" projects:
+ # - Debian id is 2
+ # - next is 1987
+ if ($self->group_id) {
+ $projects
+ = $self->api->paginator('group_projects', $self->group_id,
+ $options)->all;
+ } elsif ($self->user_id) {
+ $projects
+ = $self->api->paginator('user_projects', $self->user_id,
+ $options)->all;
+ } else {
+ ds_warn "Missing or invalid token";
+ return 1;
+ }
+ unless ($projects) {
+ ds_warn "No projects found";
+ return 1;
+ }
+ @repos = map {
+ $self->projectCache->{ $_->{path_with_namespace} } = $_->{id};
+ [$_->{id}, $_->{path}]
+ } @$projects;
+ if (@{ $self->config->skip }) {
+ @repos = map {
+ my $res = 1;
+ foreach my $k (@{ $self->config->skip }) {
+ $res = 0 if ($_->[1] =~ m#(?:.*/)?\Q$k\E#);
+ }
+ $res ? $_ : ();
+ } @repos;
+ }
+ if ($ds_yes > 0 or !$prompt) {
+ ds_verbose "Found " . @repos . " projects";
+ } else {
+ unless (
+ ds_prompt(
+ "You're going to configure "
+ . @repos
+ . " projects. Continue (N/y) "
+ ) =~ accept
+ ) {
+ ds_warn "Aborting";
+ return 1;
+ }
+ }
+ } else {
+ @repos = map { [$self->project2id($_), $_] } @reponames;
+ }
+ return @repos;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/add_user.pm b/lib/Devscripts/Salsa/add_user.pm
new file mode 100644
index 0000000..3968fb3
--- /dev/null
+++ b/lib/Devscripts/Salsa/add_user.pm
@@ -0,0 +1,40 @@
+# Adds a user in a group with a role
+package Devscripts::Salsa::add_user;
+
+use strict;
+use Devscripts::Output;
+use Moo::Role;
+
+sub add_user {
+ my ($self, $level, $user) = @_;
+ unless ($level and $user) {
+ ds_warn "Usage $0 --group-id 1234 add_user <level> <userid>";
+ return 1;
+ }
+ unless ($self->group_id) {
+ ds_warn "Unable to add user without --group or --group-id";
+ return 1;
+ }
+
+ my $id = $self->username2id($user) or return 1;
+ my $al = $self->levels_name($level) or return 1;
+ return 1
+ if (
+ $ds_yes < 0
+ and ds_prompt(
+"You're going to accept $user as $level in group $self->{group_id}. Continue (Y/n) "
+ ) =~ refuse
+ );
+ $self->api->add_group_member(
+ $self->group_id,
+ {
+ user_id => $id,
+ access_level => $al,
+ });
+ ds_warn "User $user added to group "
+ . $self->group_id
+ . " with role $level";
+ return 0;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/check_repo.pm b/lib/Devscripts/Salsa/check_repo.pm
new file mode 100755
index 0000000..c9de322
--- /dev/null
+++ b/lib/Devscripts/Salsa/check_repo.pm
@@ -0,0 +1,224 @@
+# Parses repo to check if parameters are well set
+package Devscripts::Salsa::check_repo;
+
+use strict;
+use Devscripts::Output;
+use Digest::MD5 qw(md5_hex);
+use Digest::file qw(digest_file_hex);
+use LWP::UserAgent;
+use Moo::Role;
+
+with "Devscripts::Salsa::Repo";
+
+sub check_repo {
+ my $self = shift;
+ my ($res) = $self->_check_repo(@_);
+ return $res;
+}
+
+sub _url_md5_hex {
+ my $url = shift;
+ my $ua = LWP::UserAgent->new;
+ my $res = $ua->get($url, "User-Agent" => "Devscripts/2.22.3",);
+ if (!$res->is_success) {
+ return undef;
+ }
+ return Digest::MD5::md5_hex($res->content);
+}
+
+sub _check_repo {
+ my ($self, @reponames) = @_;
+ my $res = 0;
+ my @fail;
+ unless (@reponames or $self->config->all or $self->config->all_archived) {
+ ds_warn "Usage $0 check_repo <--all|--all-archived|names>";
+ return 1;
+ }
+ if (@reponames and $self->config->all) {
+ ds_warn "--all with a reponame makes no sense";
+ return 1;
+ }
+ if (@reponames and $self->config->all_archived) {
+ ds_warn "--all-archived with a reponame makes no sense";
+ return 1;
+ }
+ # Get repo list from Devscripts::Salsa::Repo
+ my @repos = $self->get_repo(0, @reponames);
+ return @repos unless (ref $repos[0]);
+ foreach my $repo (@repos) {
+ my @err;
+ my ($id, $name) = @$repo;
+ my $project = eval { $self->api->project($id) };
+ unless ($project) {
+ ds_debug $@;
+ ds_warn "Project $name not found";
+ next;
+ }
+ ds_debug "Checking $name ($id)";
+ # check description
+ my %prms = $self->desc($name);
+ my %prms_multipart = $self->desc_multipart($name);
+ if ($self->config->desc) {
+ $project->{description} //= '';
+ push @err, "bad description: $project->{description}"
+ if ($prms{description} ne $project->{description});
+ }
+ # check build timeout
+ if ($self->config->desc) {
+ $project->{build_timeout} //= '';
+ push @err, "bad build_timeout: $project->{build_timeout}"
+ if ($prms{build_timeout} ne $project->{build_timeout});
+ }
+ # check features (w/permission) & ci config
+ foreach (qw(
+ analytics_access_level
+ auto_devops_enabled
+ builds_access_level
+ ci_config_path
+ container_registry_access_level
+ environments_access_level
+ feature_flags_access_level
+ forking_access_level
+ infrastructure_access_level
+ issues_access_level
+ lfs_enabled
+ merge_requests_access_level
+ monitor_access_level
+ packages_enabled
+ pages_access_level
+ releases_access_level
+ remove_source_branch_after_merge
+ repository_access_level
+ request_access_enabled
+ requirements_access_level
+ security_and_compliance_access_level
+ service_desk_enabled
+ snippets_access_level
+ wiki_access_level
+ )
+ ) {
+ my $helptext = '';
+ $helptext = ' (enabled)'
+ if (defined $prms{$_} and $prms{$_} eq 1);
+ $helptext = ' (disabled)'
+ if (defined $prms{$_} and $prms{$_} eq 0);
+ push @err, "$_ should be $prms{$_}$helptext"
+ if (defined $prms{$_}
+ and (!defined($project->{$_}) or $project->{$_} ne $prms{$_}));
+ }
+ # only public projects are accepted
+ push @err, "Project visibility: $project->{visibility}"
+ unless ($project->{visibility} eq "public");
+ # Default branch
+ if ($self->config->rename_head) {
+ push @err, "Default branch: $project->{default_branch}"
+ if ($project->{default_branch} ne $self->config->dest_branch);
+ }
+ # Webhooks (from Devscripts::Salsa::Hooks)
+ my $hooks = $self->enabled_hooks($id);
+ unless (defined $hooks) {
+ ds_warn "Unable to get $name hooks";
+ next;
+ }
+ # check avatar's path
+ if ($self->config->avatar_path) {
+ my ($md5_file, $md5_url) = "";
+ if ($prms_multipart{avatar}) {
+ ds_verbose "Calculating local avatar checksum";
+ $md5_file = digest_file_hex($prms_multipart{avatar}, "MD5")
+ or die "$prms_multipart{avatar} failed md5: $!";
+ if ( $project->{avatar_url}
+ and $project->{visibility} eq "public") {
+ ds_verbose "Calculating remote avatar checksum";
+ $md5_url = _url_md5_hex($project->{avatar_url})
+ or die "$project->{avatar_url} failed md5: $!";
+ # Will always force avatar if it can't detect
+ } elsif ($project->{avatar_url}) {
+ ds_warn
+"$name has an avatar, but is set to $project->{visibility} project visibility thus unable to remotely check checksum";
+ }
+ push @err, "Will set the avatar to be: $prms_multipart{avatar}"
+ if (not length $md5_url or $md5_file ne $md5_url);
+ }
+ }
+ # KGB
+ if ($self->config->kgb and not $hooks->{kgb}) {
+ push @err, "kgb missing";
+ } elsif ($self->config->disable_kgb and $hooks->{kgb}) {
+ push @err, "kgb enabled";
+ } elsif ($self->config->kgb) {
+ push @err,
+ "bad irc channel: "
+ . substr($hooks->{kgb}->{url},
+ length($self->config->kgb_server_url))
+ if $hooks->{kgb}->{url} ne $self->config->kgb_server_url
+ . $self->config->irc_channel->[0];
+ my @wopts = @{ $self->config->kgb_options };
+ my @gopts = sort @{ $hooks->{kgb}->{options} };
+ my $i = 0;
+ while (@gopts and @wopts) {
+ my $a;
+ $a = ($wopts[0] cmp $gopts[0]);
+ if ($a == -1) {
+ push @err, "Missing KGB option " . shift(@wopts);
+ } elsif ($a == 1) {
+ push @err, 'Unwanted KGB option ' . shift(@gopts);
+ } else {
+ shift @wopts;
+ shift @gopts;
+ }
+ }
+ push @err, map { "Missing KGB option $_" } @wopts;
+ push @err, map { "Unwanted KGB option $_" } @gopts;
+ }
+ # Email-on-push
+ if ($self->config->email
+ and not($hooks->{email} and %{ $hooks->{email} })) {
+ push @err, "email-on-push missing";
+ } elsif (
+ $self->config->email
+ and $hooks->{email}->{recipients} ne join(
+ ' ',
+ map {
+ my $a = $_;
+ my $b = $name;
+ $b =~ s#.*/##;
+ $a =~ s/%p/$b/;
+ $a
+ } @{ $self->config->email_recipient })
+ ) {
+ push @err, "bad email recipients " . $hooks->{email}->{recipients};
+ } elsif ($self->config->disable_email and $hooks->{kgb}) {
+ push @err, "email-on-push enabled";
+ }
+ # Irker
+ if ($self->config->irker and not $hooks->{irker}) {
+ push @err, "irker missing";
+ } elsif ($self->config->irker
+ and $hooks->{irker}->{recipients} ne
+ join(' ', map { "#$_" } @{ $self->config->irc_channel })) {
+ push @err, "bad irc channel: " . $hooks->{irker}->{recipients};
+ } elsif ($self->config->disable_irker and $hooks->{irker}) {
+ push @err, "irker enabled";
+ }
+ # Tagpending
+ if ($self->config->tagpending and not $hooks->{tagpending}) {
+ push @err, "tagpending missing";
+ } elsif ($self->config->disable_tagpending
+ and $hooks->{tagpending}) {
+ push @err, "tagpending enabled";
+ }
+ # report errors
+ if (@err) {
+ $res++;
+ push @fail, $name;
+ print "$name:\n";
+ print "\t$_\n" foreach (@err);
+ } else {
+ ds_verbose "$name: OK";
+ }
+ }
+ return ($res, \@fail);
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/checkout.pm b/lib/Devscripts/Salsa/checkout.pm
new file mode 100644
index 0000000..40052a0
--- /dev/null
+++ b/lib/Devscripts/Salsa/checkout.pm
@@ -0,0 +1,79 @@
+# Clones or updates a project's repository using gbp
+# TODO: git-dpm ?
+package Devscripts::Salsa::checkout;
+
+use strict;
+use Devscripts::Output;
+use Devscripts::Utils;
+use Dpkg::IPC;
+use Moo::Role;
+
+with "Devscripts::Salsa::Repo";
+
+sub checkout {
+ my ($self, @repos) = @_;
+ unless (@repos or $self->config->all or $self->config->all_archived) {
+ ds_warn "Usage $0 checkout <--all|--all-archived|names>";
+ return 1;
+ }
+ if (@repos and $self->config->all) {
+ ds_warn "--all with a project name makes no sense";
+ return 1;
+ }
+ if (@repos and $self->config->all_archived) {
+ ds_warn "--all-archived with a project name makes no sense";
+ return 1;
+ }
+ # If --all is asked, launch all projects
+ @repos = map { $_->[1] } $self->get_repo(0, @repos) unless (@repos);
+ my $cdir = `pwd`;
+ chomp $cdir;
+ my $res = 0;
+ foreach (@repos) {
+ my $path = $self->project2path($_);
+ s#.*/##;
+ if (-d $_) {
+ chdir $_;
+ ds_verbose "Updating existing checkout in $_";
+ spawn(
+ exec => ['gbp', 'pull', '--pristine-tar'],
+ wait_child => 1,
+ nocheck => 1,
+ );
+ if ($?) {
+ $res++;
+ if ($self->config->no_fail) {
+ print STDERR "gbp pull fails in $_\n";
+ } else {
+ ds_warn "gbp pull failed in $_\n";
+ ds_verbose "Use --no-fail to continue";
+ return 1;
+ }
+ }
+ chdir $cdir;
+ } else {
+ spawn(
+ exec => [
+ 'gbp', 'clone',
+ '--all', $self->config->git_server_url . $path . ".git"
+ ],
+ wait_child => 1,
+ nocheck => 1,
+ );
+ if ($?) {
+ $res++;
+ if ($self->config->no_fail) {
+ print STDERR "gbp clone fails in $_\n";
+ } else {
+ ds_warn "gbp clone failed for $_\n";
+ ds_verbose "Use --no-fail to continue";
+ return 1;
+ }
+ }
+ ds_warn "$_ ready in $_/";
+ }
+ }
+ return $res;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/create_repo.pm b/lib/Devscripts/Salsa/create_repo.pm
new file mode 100644
index 0000000..149de18
--- /dev/null
+++ b/lib/Devscripts/Salsa/create_repo.pm
@@ -0,0 +1,47 @@
+# Creates project using name or path
+package Devscripts::Salsa::create_repo; # create_project
+
+use strict;
+use Devscripts::Output;
+use Dpkg::IPC;
+use Moo::Role;
+
+with "Devscripts::Salsa::Hooks";
+
+sub create_repo {
+ my ($self, $reponame) = @_;
+ unless ($reponame) {
+ ds_warn "Project name is missing";
+ return 1;
+ }
+ # Get parameters from Devscripts::Salsa::Repo
+ my $opts = {
+ name => $reponame,
+ path => $reponame,
+ visibility => 'public',
+ $self->desc($reponame),
+ };
+ if ($self->group_id) {
+ $opts->{namespace_id} = $self->group_id;
+ }
+ return 1
+ if (
+ $ds_yes < 0
+ and ds_prompt(
+ "You're going to create $reponame in "
+ . ($self->group_id ? $self->group_path : 'your namespace')
+ . ". Continue (Y/n) "
+ ) =~ refuse
+ );
+ my $repo = eval { $self->api->create_project($opts) };
+ if ($@ or !$repo) {
+ ds_warn "Project not created: $@";
+ return 1;
+ }
+ ds_warn "Project $repo->{web_url} created";
+ $reponame =~ s#^.*/##;
+ $self->add_hooks($repo->{id}, $reponame);
+ return 0;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/del_repo.pm b/lib/Devscripts/Salsa/del_repo.pm
new file mode 100644
index 0000000..2573b64
--- /dev/null
+++ b/lib/Devscripts/Salsa/del_repo.pm
@@ -0,0 +1,26 @@
+# Deletes a project
+package Devscripts::Salsa::del_repo; # delete_project
+
+use strict;
+use Devscripts::Output;
+use Dpkg::IPC;
+use Moo::Role;
+
+sub del_repo {
+ my ($self, $reponame) = @_;
+ unless ($reponame) {
+ ds_warn "Project name or path is missing";
+ return 1;
+ }
+ my $id = $self->project2id($reponame) or return 1;
+ my $path = $self->project2path($reponame);
+ return 1
+ if ($ds_yes < 0
+ and ds_prompt("You're going to delete $path. Continue (Y/n) ")
+ =~ refuse);
+ $self->api->delete_project($id);
+ ds_warn "Project $path deleted";
+ return 0;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/del_user.pm b/lib/Devscripts/Salsa/del_user.pm
new file mode 100644
index 0000000..5fb2f79
--- /dev/null
+++ b/lib/Devscripts/Salsa/del_user.pm
@@ -0,0 +1,32 @@
+# Removes a user from a group
+package Devscripts::Salsa::del_user; # delete_user
+
+use strict;
+use Devscripts::Output;
+use Moo::Role;
+
+sub del_user {
+ my ($self, $user) = @_;
+ unless ($user) {
+ ds_warn "Usage $0 delete_user <user>";
+ return 1;
+ }
+ unless ($self->group_id) {
+ ds_warn "Unable to remove user without --group-id";
+ return 1;
+ }
+
+ my $id = $self->username2id($user) or return 1;
+ return 1
+ if (
+ $ds_yes < 0
+ and ds_prompt(
+"You're going to remove $user from group $self->{group_id}. Continue (Y/n) "
+ ) =~ refuse
+ );
+ $self->api->remove_group_member($self->group_id, $id);
+ ds_warn "User $user removed from group " . $self->group_id;
+ return 0;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/fork.pm b/lib/Devscripts/Salsa/fork.pm
new file mode 100644
index 0000000..ca5559e
--- /dev/null
+++ b/lib/Devscripts/Salsa/fork.pm
@@ -0,0 +1,36 @@
+# Forks a project given by full path into group/user namespace
+package Devscripts::Salsa::fork;
+
+use strict;
+use Devscripts::Output;
+use Dpkg::IPC;
+use Moo::Role;
+
+with 'Devscripts::Salsa::checkout';
+
+sub fork {
+ my ($self, $project) = @_;
+ unless ($project) {
+ ds_warn "Project to fork is missing";
+ return 1;
+ }
+ my $path = $self->main_path or return 1;
+ $self->api->fork_project($project, { namespace => $path });
+ my $p = $project;
+ $p =~ s#.*/##;
+ if ($self->checkout($p)) {
+ ds_warn "Failed to checkout $project";
+ return 1;
+ }
+ chdir $p;
+ spawn(
+ exec => [
+ qw(git remote add upstream),
+ $self->config->git_server_url . $project
+ ],
+ wait_child => 1
+ );
+ return 0;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/forks.pm b/lib/Devscripts/Salsa/forks.pm
new file mode 100644
index 0000000..0d7f710
--- /dev/null
+++ b/lib/Devscripts/Salsa/forks.pm
@@ -0,0 +1,45 @@
+# Lists forks of a project
+package Devscripts::Salsa::forks;
+
+use strict;
+use Devscripts::Output;
+use Moo::Role;
+
+sub forks {
+ my ($self, @reponames) = @_;
+ my $res = 0;
+ unless (@reponames) {
+ ds_warn "Project name is missing";
+ return 1;
+ }
+ foreach my $p (@reponames) {
+ my $id = $self->project2id($p);
+ unless ($id) {
+ ds_warn "Project $_ not found";
+ $res++;
+ next;
+ }
+ print "$p\n";
+ my $forks = $self->api->paginator(
+ 'project_forks',
+ $id,
+ {
+ state => 'opened',
+ });
+ unless ($forks) {
+ print "\n";
+ next;
+ }
+ while ($_ = $forks->next) {
+ print <<END;
+\tId : $_->{id}
+\tName: $_->{path_with_namespace}
+\tURL : $_->{web_url}
+
+END
+ }
+ }
+ return $res;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/group.pm b/lib/Devscripts/Salsa/group.pm
new file mode 100644
index 0000000..f603ecc
--- /dev/null
+++ b/lib/Devscripts/Salsa/group.pm
@@ -0,0 +1,35 @@
+# Lists members of a group
+package Devscripts::Salsa::group; # list_users
+
+use strict;
+use Devscripts::Output;
+use Moo::Role;
+
+sub group {
+ my ($self) = @_;
+ my $count = 0;
+ unless ($self->group_id) {
+ ds_warn "Usage $0 --group-id 1234 list_users";
+ return 1;
+ }
+ my $users = $self->api->paginator('group_members', $self->group_id);
+ while ($_ = $users->next) {
+ $count++;
+ my $access_level = $self->levels_code($_->{access_level});
+ print <<END;
+Id : $_->{id}
+Username : $_->{username}
+Name : $_->{name}
+Access level: $access_level
+State : $_->{state}
+
+END
+ }
+ unless ($count) {
+ ds_warn "No users found";
+ return 1;
+ }
+ return 0;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/join.pm b/lib/Devscripts/Salsa/join.pm
new file mode 100644
index 0000000..319e107
--- /dev/null
+++ b/lib/Devscripts/Salsa/join.pm
@@ -0,0 +1,20 @@
+# Launch request to join a group
+package Devscripts::Salsa::join;
+
+use strict;
+use Devscripts::Output;
+use Moo::Role;
+
+sub join {
+ my ($self, $group) = @_;
+ unless ($group ||= $self->config->group || $self->config->group_id) {
+ ds_warn "Group is missing";
+ return 1;
+ }
+ my $gid = $self->group2id($group);
+ $self->api->group_access_requests($gid);
+ ds_warn "Request launched to group $group ($gid)";
+ return 0;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/last_ci_status.pm b/lib/Devscripts/Salsa/last_ci_status.pm
new file mode 100644
index 0000000..2292c30
--- /dev/null
+++ b/lib/Devscripts/Salsa/last_ci_status.pm
@@ -0,0 +1,77 @@
+package Devscripts::Salsa::last_ci_status;
+
+use strict;
+use Devscripts::Output;
+use Moo::Role;
+
+with "Devscripts::Salsa::Repo";
+
+use constant OK => 'success';
+use constant SKIPPED => 'skipped';
+use constant FAILED => 'failed';
+
+sub last_ci_status {
+ my ($self, @repos) = @_;
+ unless (@repos or $self->config->all or $self->config->all_archived) {
+ ds_warn "Usage $0 ci_status <--all|--all-archived|names>";
+ return 1;
+ }
+ if (@repos and $self->config->all) {
+ ds_warn "--all with a project name makes no sense";
+ return 1;
+ }
+ if (@repos and $self->config->all_archived) {
+ ds_warn "--all-archived with a project name makes no sense";
+ return 1;
+ }
+ # If --all is asked, launch all projects
+ @repos = map { $_->[1] } $self->get_repo(0, @repos) unless (@repos);
+ my $ret = 0;
+ foreach my $repo (@repos) {
+ my $id = $self->project2id($repo) or return 1;
+ my $pipelines = $self->api->pipelines($id);
+ unless ($pipelines and @$pipelines) {
+ ds_warn "No pipelines for $repo";
+ $ret++;
+ unless ($self->config->no_fail) {
+ ds_verbose "Use --no-fail to continue";
+ return 1;
+ }
+ } else {
+ my $status = $pipelines->[0]->{status};
+ if ($status eq OK) {
+ print "Last result for $repo: $status\n";
+ } else {
+ print STDERR "Last result for $repo: $status\n";
+ my $jobs
+ = $self->api->pipeline_jobs($id, $pipelines->[0]->{id});
+ my %jres;
+ foreach my $job (sort { $a->{id} <=> $b->{id} } @$jobs) {
+ next if $job->{status} eq SKIPPED;
+ push @{ $jres{ $job->{status} } }, $job->{name};
+ }
+ if ($jres{ OK() }) {
+ print STDERR ' success: '
+ . join(', ', @{ $jres{ OK() } }) . "\n";
+ delete $jres{ OK() };
+ }
+ foreach my $k (sort keys %jres) {
+ print STDERR ' '
+ . uc($k) . ': '
+ . join(', ', @{ $jres{$k} }) . "\n";
+ }
+ print STDERR "\n See: " . $pipelines->[0]->{web_url} . "\n\n";
+ if ($status eq FAILED) {
+ $ret++;
+ unless ($self->config->no_fail) {
+ ds_verbose "Use --no-fail to continue";
+ return 1;
+ }
+ }
+ }
+ }
+ }
+ return $ret;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/list_groups.pm b/lib/Devscripts/Salsa/list_groups.pm
new file mode 100644
index 0000000..903cd1e
--- /dev/null
+++ b/lib/Devscripts/Salsa/list_groups.pm
@@ -0,0 +1,40 @@
+# Lists subgroups of a group or groups of a user
+package Devscripts::Salsa::list_groups;
+
+use strict;
+use Devscripts::Output;
+use Moo::Role;
+
+sub list_groups {
+ my ($self, $match) = @_;
+ my $groups;
+ my $count = 0;
+ my $opts = {
+ order_by => 'name',
+ sort => 'asc',
+ ($match ? (search => $match) : ()),
+ };
+ if ($self->group_id) {
+ $groups
+ = $self->api->paginator('group_subgroups', $self->group_id, $opts);
+ } else {
+ $groups = $self->api->paginator('groups', $opts);
+ }
+ while ($_ = $groups->next) {
+ $count++;
+ my $parent = $_->{parent_id} ? "Parent id: $_->{parent_id}\n" : '';
+ print <<END;
+Id : $_->{id}
+Name : $_->{name}
+Full path: $_->{full_path}
+$parent
+END
+ }
+ unless ($count) {
+ ds_warn "No groups found";
+ return 1;
+ }
+ return 0;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/list_repos.pm b/lib/Devscripts/Salsa/list_repos.pm
new file mode 100644
index 0000000..29d5fec
--- /dev/null
+++ b/lib/Devscripts/Salsa/list_repos.pm
@@ -0,0 +1,42 @@
+# Lists projects of group/user
+package Devscripts::Salsa::list_repos; # list_projects
+
+use strict;
+use Devscripts::Output;
+use Moo::Role;
+
+sub list_repos {
+ my ($self, $match) = @_;
+ my $projects;
+ my $count = 0;
+ my $opts = {
+ order_by => 'name',
+ sort => 'asc',
+ simple => 1,
+ archived => $self->config->archived,
+ ($match ? (search => $match) : ()),
+ };
+ if ($self->group_id) {
+ $projects
+ = $self->api->paginator('group_projects', $self->group_id, $opts);
+ } else {
+ $projects
+ = $self->api->paginator('user_projects', $self->user_id, $opts);
+ }
+ while ($_ = $projects->next) {
+ $count++;
+ print <<END;
+Id : $_->{id}
+Name: $_->{name}
+URL : $_->{web_url}
+
+END
+ }
+ unless ($count) {
+ ds_warn "No projects found";
+ return 1;
+ }
+ return 0;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/merge_request.pm b/lib/Devscripts/Salsa/merge_request.pm
new file mode 100644
index 0000000..89152aa
--- /dev/null
+++ b/lib/Devscripts/Salsa/merge_request.pm
@@ -0,0 +1,174 @@
+# Creates a merge request from current directory (or using parameters)
+package Devscripts::Salsa::merge_request;
+
+use strict;
+use Devscripts::Output;
+use Dpkg::IPC;
+use Moo::Role;
+
+with 'Devscripts::Salsa::search_project'; # search_projects
+
+sub merge_request {
+ my ($self, $dst_project, $dst_branch) = @_;
+ my $src_branch = $self->config->mr_src_branch;
+ my $src_project = $self->config->mr_src_project;
+ $dst_project ||= $self->config->mr_dst_project;
+ $dst_branch ||= $self->config->mr_dst_branch;
+ my $title = $self->config->mr_title;
+ my $desc = $self->config->mr_desc;
+
+ if ($src_branch) {
+ unless ($src_project and $dst_project) {
+ ds_warn "--mr-src-project and --mr-src-project "
+ . "are required when --mr-src-branch is set";
+ return 1;
+ }
+ unless ($src_project =~ m#/#) {
+ $src_project = $self->project2path($src_project);
+ }
+ } else { # Use current repository to find elements
+ ds_verbose "using current branch as source";
+ my $out;
+ unless ($src_project) {
+ # 1. Verify that project is ready
+ spawn(
+ exec => [qw(git status -s -b -uno)],
+ wait_child => 1,
+ to_string => \$out
+ );
+ chomp $out;
+ # Case "rebased"
+ if ($out =~ /\[/) {
+ ds_warn "Current branch isn't pushed, aborting:\n";
+ return 1;
+ }
+ # Case else: nothing after src...dst
+ unless ($out =~ /\s(\S+)\.\.\.(\S+)/s) {
+ ds_warn
+ "Current branch as no origin or isn't pushed, aborting";
+ return 1;
+ }
+ # 2. Set source branch to current branch
+ $src_branch ||= $1;
+ ds_verbose "Found current branch: $src_branch";
+ }
+ unless ($src_project and $dst_project) {
+ # Check remote links
+ spawn(
+ exec => [qw(git remote --verbose show)],
+ wait_child => 1,
+ to_string => \$out,
+ );
+ my $origin = $self->config->api_url;
+ $origin =~ s#api/v4$##;
+ # 3. Set source project using "origin" target
+ unless ($src_project) {
+ if ($out
+ =~ /origin\s+(?:\Q$self->{config}->{git_server_url}\E|\Q$origin\E)(\S*)/m
+ ) {
+ $src_project = $1;
+ $src_project =~ s/\.git$//;
+ } else {
+ ds_warn
+"Unable to find project origin, set it using --mr-src-project";
+ return 1;
+ }
+ }
+ # 4. Steps to find destination project:
+ # - command-line
+ # - GitLab API (search for "forked_from_project"
+ # - "upstream" in git remote
+ # - use source project as destination project
+
+ # 4.1. Stop if dest project has been given in command line
+ unless ($dst_project) {
+ my $project = $self->api->project($src_project);
+
+ # 4.2. Search original project from GitLab API
+ if ($project->{forked_from_project}) {
+ $dst_project
+ = $project->{forked_from_project}->{path_with_namespace};
+ }
+ if ($dst_project) {
+ ds_verbose "Project was forked from $dst_project";
+
+ # 4.3. Search for an "upstream" target in `git remote`
+ } elsif ($out
+ =~ /upstream\s+(?:\Q$self->{config}->{git_server_url}\E|\Q$origin\E)(\S*)/m
+ ) {
+ $dst_project = $1;
+ $dst_project =~ s/\.git$//;
+ ds_verbose 'Use "upstream" target as dst project';
+ # 4.4. Use source project as destination
+ } else {
+ ds_warn
+"No upstream target found, using current project as target";
+ $dst_project = $src_project;
+ }
+ ds_verbose "Use $dst_project as dest project";
+ }
+ }
+ # 5. Search for MR title and desc
+ unless ($title) {
+ ds_warn "Title not set, using last commit";
+ spawn(
+ exec => ['git', 'show', '--format=format:%s###%b'],
+ wait_child => 1,
+ to_string => \$out,
+ );
+ $out =~ s/\ndiff.*$//s;
+ my ($t, $d) = split /###/, $out;
+ chomp $d;
+ $title = $t;
+ ds_verbose "Title set to $title";
+ $desc ||= $d;
+ # Replace all bug links by markdown links
+ if ($desc) {
+ $desc =~ s@#(\d{6,})\b@[#$1](https://bugs.debian.org/$1)@mg;
+ ds_verbose "Desc set to $desc";
+ }
+ }
+ }
+ if ($dst_project eq 'same') {
+ $dst_project = $src_project;
+ }
+ my $src = $self->api->project($src_project);
+ unless ($title) {
+ ds_warn "Title is required";
+ return 1;
+ }
+ unless ($src and $src->{id}) {
+ ds_warn "Target project not found $src_project";
+ return 1;
+ }
+ my $dst;
+ if ($dst_project) {
+ $dst = $self->api->project($dst_project);
+ unless ($dst and $dst->{id}) {
+ ds_warn "Target project not found";
+ return 1;
+ }
+ }
+ return 1
+ if (
+ ds_prompt(
+"You're going to push an MR to $dst_project:$dst_branch. Continue (Y/n)"
+ ) =~ refuse
+ );
+ my $res = $self->api->create_merge_request(
+ $src->{id},
+ {
+ source_branch => $src_branch,
+ target_branch => $dst_branch,
+ title => $title,
+ remove_source_branch => $self->config->mr_remove_source_branch,
+ squash => $self->config->mr_allow_squash,
+ ($dst ? (target_project_id => $dst->{id}) : ()),
+ ($desc ? (description => $desc) : ()),
+ });
+ ds_warn "MR '$title' posted:";
+ ds_warn $res->{web_url};
+ return 0;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/merge_requests.pm b/lib/Devscripts/Salsa/merge_requests.pm
new file mode 100644
index 0000000..f647689
--- /dev/null
+++ b/lib/Devscripts/Salsa/merge_requests.pm
@@ -0,0 +1,49 @@
+# Lists merge requests proposed to a project
+package Devscripts::Salsa::merge_requests;
+
+use strict;
+use Devscripts::Output;
+use Moo::Role;
+
+sub merge_requests {
+ my ($self, @reponames) = @_;
+ my $res = 1;
+ unless (@reponames) {
+ ds_warn "project name is missing";
+ return 1;
+ }
+ foreach my $p (@reponames) {
+ my $id = $self->project2id($p);
+ my $count = 0;
+ unless ($id) {
+ ds_warn "Project $_ not found";
+ return 1;
+ }
+ print "$p\n";
+ my $mrs = $self->api->paginator(
+ 'merge_requests',
+ $id,
+ {
+ state => 'opened',
+ });
+ while ($_ = $mrs->next) {
+ $res = 0;
+ my $status = $_->{work_in_progress} ? 'WIP' : $_->{merge_status};
+ print <<END;
+\tId : $_->{id}
+\tTitle : $_->{title}
+\tAuthor: $_->{author}->{username}
+\tStatus: $status
+\tUrl : $_->{web_url}
+
+END
+ }
+ unless ($count) {
+ print "\n";
+ next;
+ }
+ }
+ return $res;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/pipeline_schedule.pm b/lib/Devscripts/Salsa/pipeline_schedule.pm
new file mode 100755
index 0000000..37ab9f6
--- /dev/null
+++ b/lib/Devscripts/Salsa/pipeline_schedule.pm
@@ -0,0 +1,127 @@
+# Create a pipeline schedule using parameters
+package Devscripts::Salsa::pipeline_schedule;
+
+use strict;
+use Devscripts::Output;
+use Moo::Role;
+
+# For --all
+with "Devscripts::Salsa::Repo";
+
+sub pipeline_schedule {
+ my ($self, @repos) = @_;
+ my $ret = 0;
+ my $desc = $self->config->schedule_desc;
+ my $ref = $self->config->schedule_ref;
+ my $cron = $self->config->schedule_cron;
+ my $tz = $self->config->schedule_tz;
+ my $active = $self->config->schedule_enable;
+ $active
+ = ($self->config->schedule_disable)
+ ? "0"
+ : $active;
+ my $run = $self->config->schedule_run;
+ my $delete = $self->config->schedule_delete;
+
+ unless (@repos or $self->config->all) {
+ ds_warn "Usage $0 pipeline <project|--all>";
+ return 1;
+ }
+ if (@repos and $self->config->all) {
+ ds_warn "--all with a project (@repos) makes no sense";
+ return 1;
+ }
+
+ unless ($desc) {
+ ds_warn "--schedule-desc / SALSA_SCHEDULE_DESC is missing";
+ ds_warn "Are you looking for: $0 pipelines <project|--all>";
+ return 1;
+ }
+
+ # If --all is asked, launch all projects
+ @repos = map { $_->[1] } $self->get_repo(0, @repos) unless (@repos);
+
+ foreach my $repo (sort @repos) {
+ my $id = $self->project2id($repo);
+ unless ($id) {
+#ds_warn "Project $repo not found"; # $self->project2id($repo) shows this error
+ $ret++;
+ unless ($self->config->no_fail) {
+ ds_verbose "Use --no-fail to continue";
+ return 1;
+ }
+ } else {
+ my @pipe_id = ();
+ $desc =~ s/%p/$repo/g;
+ my $options = {};
+ $options->{ref} = $ref if defined $ref;
+ $options->{cron} = $cron if defined $cron;
+ $options->{cron_timezone} = $tz if defined $tz;
+ $options->{active} = $active if defined $active;
+
+# REF: https://docs.gitlab.com/ee/api/pipeline_schedules.html#get-all-pipeline-schedules
+# $self->api->pipeline_schedules($id)
+ my $pipelines
+ = $self->api->paginator('pipeline_schedules', $id)->all();
+ ds_verbose "No pipelines scheduled for $repo" unless @$pipelines;
+
+ foreach (@$pipelines) {
+ push @pipe_id, $_->{id}
+ if ($_->{description} eq $desc);
+ }
+
+ ds_warn "More than 1 scheduled pipeline matches: $desc ("
+ . ++$#pipe_id . ")"
+ if ($pipe_id[1]);
+
+ if (!@pipe_id) {
+ ds_warn "--schedule-ref / SALSA_SCHEDULE_REF is required"
+ unless ($ref);
+ ds_warn "--schedule-cron / SALSA_SCHEDULE_CRON is required"
+ unless ($cron);
+ return 1
+ unless ($ref && $cron);
+
+ $options->{description} = $desc if defined $desc;
+
+ ds_verbose "No scheduled pipelines matching: $desc. Creating!";
+ my $schedule
+ = $self->api->create_pipeline_schedule($id, $options);
+
+ @pipe_id = $schedule->{id};
+ } elsif (keys %$options) {
+ ds_verbose "Editing scheduled pipelines matching: $desc";
+ foreach (@pipe_id) {
+ next if !$_;
+
+ my $schedule
+ = $self->api->edit_pipeline_schedule($id, $_, $options);
+ }
+ }
+
+ if ($run) {
+ ds_verbose "Running scheduled pipelines matching: $desc";
+
+ foreach (@pipe_id) {
+ next if !$_;
+
+ my $schedule = $self->api->run_pipeline_schedule($id, $_);
+ }
+ }
+
+ if ($delete) {
+ ds_verbose "Deleting scheduled pipelines matching: $desc";
+
+ foreach (@pipe_id) {
+ next if !$_;
+
+ my $schedule
+ = $self->api->delete_pipeline_schedule($id, $_);
+ }
+ }
+ }
+ }
+ return $ret;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/pipeline_schedules.pm b/lib/Devscripts/Salsa/pipeline_schedules.pm
new file mode 100755
index 0000000..66ad08a
--- /dev/null
+++ b/lib/Devscripts/Salsa/pipeline_schedules.pm
@@ -0,0 +1,73 @@
+# Lists pipeline schedules of a project
+package Devscripts::Salsa::pipeline_schedules;
+
+use strict;
+use Devscripts::Output;
+use Moo::Role;
+
+# For --all
+with "Devscripts::Salsa::Repo";
+
+sub pipeline_schedules {
+ my ($self, @repo) = @_;
+ my $ret = 0;
+
+ unless (@repo or $self->config->all) {
+ ds_warn "Usage $0 pipelines <project|--all>";
+ return 1;
+ }
+ if (@repo and $self->config->all) {
+ ds_warn "--all with a project (@repo) makes no sense";
+ return 1;
+ }
+
+ # If --all is asked, launch all projects
+ @repo = map { $_->[1] } $self->get_repo(0, @repo) unless (@repo);
+
+ foreach my $p (sort @repo) {
+ my $id = $self->project2id($p);
+ my $count = 0;
+ unless ($id) {
+ #ds_warn "Project $p not found"; # $self->project2id($p) shows this error
+ $ret++;
+ unless ($self->config->no_fail) {
+ ds_verbose "Use --no-fail to continue";
+ return 1;
+ }
+ } else {
+ my $projects = $self->api->project($id);
+ if ($projects->{jobs_enabled} == 0) {
+ print "$p has disabled CI/CD\n";
+ next;
+ }
+
+ my $pipelines
+ = $self->api->paginator('pipeline_schedules', $id)->all();
+
+ print "$p\n" if @$pipelines;
+
+ foreach (@$pipelines) {
+ my $status = $_->{active} ? 'Enabled' : 'Disabled';
+ print <<END;
+\tID : $_->{id}
+\tDescription: $_->{description}
+\tStatus : $status
+\tRef : $_->{ref}
+\tCron : $_->{cron}
+\tTimezone : $_->{cron_timezone}
+\tCreated : $_->{created_at}
+\tUpdated : $_->{updated_at}
+\tNext run : $_->{next_run_at}
+\tOwner : $_->{owner}->{username}
+
+END
+ }
+ }
+ unless ($count) {
+ next;
+ }
+ }
+ return $ret;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/protect_branch.pm b/lib/Devscripts/Salsa/protect_branch.pm
new file mode 100644
index 0000000..2af6faf
--- /dev/null
+++ b/lib/Devscripts/Salsa/protect_branch.pm
@@ -0,0 +1,43 @@
+# Protects a branch
+package Devscripts::Salsa::protect_branch;
+
+use strict;
+use Devscripts::Output;
+use Moo::Role;
+
+use constant levels => {
+ o => 50,
+ owner => 50,
+ m => 40,
+ maintainer => 40,
+ d => 30,
+ developer => 30,
+ r => 20,
+ reporter => 20,
+ g => 10,
+ guest => 10,
+};
+
+sub protect_branch {
+ my ($self, $reponame, $branch, $merge, $push) = @_;
+ unless ($reponame and $branch) {
+ ds_warn "usage: $0 protect_branch project branch merge push";
+ return 1;
+ }
+ if (defined $merge and $merge =~ /^(?:no|0)$/i) {
+ $self->api->unprotect_branch($self->project2id($reponame), $branch);
+ return 0;
+ }
+ unless (levels->{$merge} and levels->{$push}) {
+ ds_warn
+ "usage: $0 protect_branch project branch <merge level> <push level>";
+ return 1;
+ }
+ my $opts = { name => $branch };
+ $opts->{push_access_level} = (levels->{$push});
+ $opts->{merge_access_level} = (levels->{$merge});
+ $self->api->protect_branch($self->project2id($reponame), $opts);
+ return 0;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/protected_branches.pm b/lib/Devscripts/Salsa/protected_branches.pm
new file mode 100644
index 0000000..4714d2e
--- /dev/null
+++ b/lib/Devscripts/Salsa/protected_branches.pm
@@ -0,0 +1,27 @@
+# Displays protected branches of a project
+package Devscripts::Salsa::protected_branches;
+
+use strict;
+use Devscripts::Output;
+use Moo::Role;
+
+sub protected_branches {
+ my ($self, $reponame) = @_;
+ unless ($reponame) {
+ ds_warn "Project name is missing";
+ return 1;
+ }
+ my $branches
+ = $self->api->protected_branches($self->project2id($reponame));
+ if ($branches and @$branches) {
+ printf " %-20s | %-25s | %-25s\n", 'Branch', 'Merge', 'Push';
+ foreach (@$branches) {
+ printf " %-20s | %-25s | %-25s\n", $_->{name},
+ $_->{merge_access_levels}->[0]->{access_level_description},
+ $_->{push_access_levels}->[0]->{access_level_description};
+ }
+ }
+ return 0;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/purge_cache.pm b/lib/Devscripts/Salsa/purge_cache.pm
new file mode 100644
index 0000000..187f698
--- /dev/null
+++ b/lib/Devscripts/Salsa/purge_cache.pm
@@ -0,0 +1,15 @@
+# Empties the Devscripts::JSONCache
+package Devscripts::Salsa::purge_cache;
+
+use strict;
+use Devscripts::Output;
+use Moo::Role;
+
+sub purge_cache {
+ my @keys = keys %{ $_[0]->_cache };
+ delete $_[0]->_cache->{$_} foreach (@keys);
+ ds_verbose "Cache empty";
+ return 0;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/push.pm b/lib/Devscripts/Salsa/push.pm
new file mode 100644
index 0000000..fa3b6cc
--- /dev/null
+++ b/lib/Devscripts/Salsa/push.pm
@@ -0,0 +1,106 @@
+# Push local work. Like gbp push but able to push incomplete work
+package Devscripts::Salsa::push;
+use strict;
+use Devscripts::Output;
+use Devscripts::Utils;
+use Dpkg::Source::Format;
+use Moo::Role;
+use Dpkg::IPC;
+
+sub readGbpConf {
+ my ($self) = @_;
+ my $res = '';
+ foreach my $gbpconf (qw(.gbp.conf debian/gbp.conf .git/gbp.conf)) {
+ if (-e $gbpconf) {
+ open(my $f, $gbpconf);
+ while (<$f>) {
+ $res .= $_;
+ if (/^\s*(debian|upstream)\-(branch|tag)\s*=\s*(.*\S)/) {
+ $self->{"$1_$2"} = $3;
+ }
+ }
+ close $f;
+ last;
+ }
+ }
+ if ($self->{debian_tag}) {
+ $self->{debian_tag} =~ s/%\(version\)s/.*/g;
+ $self->{debian_tag} =~ s/^/^/;
+ $self->{debian_tag} =~ s/$/\$/;
+ } else {
+ my @tmp
+ = Dpkg::Source::Format->new(filename => 'debian/source/format')->get;
+ $self->{debian_tag} = $tmp[2] eq 'native' ? '.*' : '^debian/.*$';
+ }
+ if ($self->{upstream_tag}) {
+ $self->{upstream_tag} =~ s/%\(version\)s/.*/g;
+ $self->{upstream_tag} =~ s/^/^/;
+ $self->{upstream_tag} =~ s/$/\$/;
+ } else {
+ $self->{upstream_tag} = '^upstream/.*$';
+ }
+ $self->{debian_branch} ||= 'master';
+ $self->{upstream_branch} ||= 'upstream';
+ return $res;
+}
+
+sub push {
+ my ($self) = @_;
+ $self->readGbpConf;
+ my @refs;
+ foreach (
+ $self->{debian_branch}, $self->{upstream_branch},
+ 'pristine-tar', 'refs/notes/commits'
+ ) {
+ if (ds_exec_no_fail(qw(git rev-parse --verify --quiet), $_) == 0) {
+ push @refs, $_;
+ }
+ }
+ my $out;
+ spawn(exec => ['git', 'tag'], wait_child => 1, to_string => \$out);
+ my @tags = grep /(?:$self->{debian_tag}|$self->{upstream_tag})/,
+ split(/\r?\n/, $out);
+ unless (
+ $ds_yes < 0
+ and ds_prompt(
+ "You're going to push :\n - "
+ . join(', ', @refs)
+ . "\nand check tags that match:\n - "
+ . join(', ', $self->{debian_tag}, $self->{upstream_tag})
+ . "\nContinue (Y/n) "
+ ) =~ refuse
+ ) {
+ my $origin;
+ eval {
+ spawn(
+ exec => ['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
+ wait_child => 1,
+ to_string => \$out,
+ );
+ chomp $out;
+ spawn(
+ exec =>
+ ['git', 'config', '--local', '--get', "branch.$out.remote"],
+ wait_child => 1,
+ to_string => \$origin,
+ );
+ chomp $origin;
+ };
+ if ($origin) {
+ ds_verbose 'Origin is ' . $origin;
+ } else {
+ ds_warn 'Unable to detect remote name, trying "origin"';
+ ds_verbose "Error: $@" if ($@);
+ $origin = 'origin';
+ }
+ ds_verbose "Execute 'git push $origin " . join(' ', @refs, '<tags>');
+ ds_debug "Tags are: " . join(' ', @tags);
+ spawn(
+ exec => ['git', 'push', $origin, @refs, @tags],
+ wait_child => 1
+ );
+ }
+ return 0;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/push_repo.pm b/lib/Devscripts/Salsa/push_repo.pm
new file mode 100644
index 0000000..511316d
--- /dev/null
+++ b/lib/Devscripts/Salsa/push_repo.pm
@@ -0,0 +1,71 @@
+# Creates GitLab project from local repository path
+package Devscripts::Salsa::push_repo;
+
+use strict;
+use Devscripts::Output;
+use Dpkg::IPC;
+use Moo::Role;
+
+with "Devscripts::Salsa::create_repo"; # create_project
+
+sub push_repo {
+ my ($self, $reponame) = @_;
+ unless ($reponame) {
+ ds_warn "Repository path is missing";
+ return 1;
+ }
+ unless (-d $reponame) {
+ ds_warn "$reponame isn't a directory";
+ return 1;
+ }
+ chdir $reponame;
+ eval {
+ spawn(
+ exec => ['dpkg-parsechangelog', '--show-field', 'Source'],
+ to_string => \$reponame,
+ wait_child => 1,
+ );
+ };
+ if ($@) {
+ ds_warn $@;
+ return 1;
+ }
+ chomp $reponame;
+ my $out;
+ spawn(
+ exec => ['git', 'remote', 'show'],
+ to_string => \$out,
+ wait_child => 1,
+ );
+ if ($out =~ /^origin$/m) {
+ ds_warn "git origin is already configured:\n$out";
+ return 1;
+ }
+ my $path = $self->project2path('') or return 1;
+ my $url = $self->config->git_server_url . "$path$reponame";
+ spawn(
+ exec => ['git', 'remote', 'add', 'origin', $url],
+ wait_child => 1,
+ );
+ my $res = $self->create_repo($reponame);
+ if ($res) {
+ return 1
+ unless (
+ ds_prompt(
+"Project already exists, do you want to try to push local repository? (y/N) "
+ ) =~ accept
+ );
+ }
+ spawn(
+ exec =>
+ ['git', 'push', '--all', '--verbose', '--set-upstream', 'origin'],
+ wait_child => 1,
+ );
+ spawn(
+ exec => ['git', 'push', '--tags', '--verbose', 'origin'],
+ wait_child => 1,
+ );
+ return 0;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/rename_branch.pm b/lib/Devscripts/Salsa/rename_branch.pm
new file mode 100644
index 0000000..f3a0f1b
--- /dev/null
+++ b/lib/Devscripts/Salsa/rename_branch.pm
@@ -0,0 +1,47 @@
+package Devscripts::Salsa::rename_branch;
+
+use strict;
+use Devscripts::Output;
+use Moo::Role;
+
+with "Devscripts::Salsa::Repo";
+
+our $prompt = 1;
+
+sub rename_branch {
+ my ($self, @reponames) = @_;
+ my $res = 0;
+ my @repos = $self->get_repo($prompt, @reponames);
+ return @repos unless (ref $repos[0]); # get_repo returns 1 when fails
+ foreach (@repos) {
+ my $id = $_->[0];
+ my $str = $_->[1];
+ if (!$id) {
+ ds_warn "Branch rename has failed for $str (missing ID)\n";
+ return 1;
+ }
+ ds_verbose "Configuring $str";
+ my $project = $self->api->project($id);
+ eval {
+ $self->api->create_branch(
+ $id,
+ {
+ ref => $self->config->source_branch,
+ branch => $self->config->dest_branch,
+ });
+ $self->api->delete_branch($id, $self->config->source_branch);
+ };
+ if ($@) {
+ ds_warn "Branch rename has failed for $str\n";
+ ds_verbose $@;
+ unless ($self->config->no_fail) {
+ ds_verbose "Use --no-fail to continue";
+ return 1;
+ }
+ next;
+ }
+ }
+ return $res;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/search_group.pm b/lib/Devscripts/Salsa/search_group.pm
new file mode 100644
index 0000000..c1451e9
--- /dev/null
+++ b/lib/Devscripts/Salsa/search_group.pm
@@ -0,0 +1,37 @@
+# Searches groups using given string
+package Devscripts::Salsa::search_group; # search_groups
+
+use strict;
+use Devscripts::Output;
+use Moo::Role;
+
+sub search_group {
+ my ($self, $group) = @_;
+ unless ($group) {
+ ds_warn "Searched string is missing";
+ return 1;
+ }
+ my $groups = $self->api->group_without_projects($group);
+ if ($groups) {
+ $groups = [$groups];
+ } else {
+ $groups = $self->api->paginator('groups',
+ { search => $group, order_by => 'name' })->all;
+ }
+ unless ($groups and @$groups) {
+ ds_warn "No group found";
+ return 1;
+ }
+ foreach (@$groups) {
+ print <<END;
+Id : $_->{id}
+Name : $_->{name}
+Full name: $_->{full_name}
+Full path: $_->{full_path}
+
+END
+ }
+ return 0;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/search_project.pm b/lib/Devscripts/Salsa/search_project.pm
new file mode 100644
index 0000000..7711033
--- /dev/null
+++ b/lib/Devscripts/Salsa/search_project.pm
@@ -0,0 +1,57 @@
+# Searches projects using given string
+package Devscripts::Salsa::search_project; # search_projects
+
+use strict;
+use Devscripts::Output;
+use Moo::Role;
+
+sub search_project {
+ my ($self, $project) = @_;
+ unless ($project) {
+ ds_warn "Searched string is missing";
+ return 1;
+ }
+ my $projects = $self->api->project($project);
+ if ($projects) {
+ $projects = [$projects];
+ } else {
+ $projects = $self->api->paginator(
+ 'projects',
+ {
+ search => $project,
+ order_by => 'name',
+ archived => $self->config->archived
+ })->all();
+ }
+ unless ($projects and @$projects) {
+ ds_warn "No projects found";
+ return 1;
+ }
+ foreach (@$projects) {
+ print <<END;
+Id : $_->{id}
+Name : $_->{name}
+Full path: $_->{path_with_namespace}
+END
+ print(
+ $_->{namespace}->{kind} eq 'group'
+ ? "Group id : "
+ : "User id : "
+ );
+ print "$_->{namespace}->{id}\n";
+ print(
+ $_->{namespace}->{kind} eq 'group'
+ ? "Group : "
+ : "User : "
+ );
+ print "$_->{namespace}->{name}\n";
+ if ($_->{forked_from_project} and $_->{forked_from_project}->{id}) {
+ print
+ "Fork of : $_->{forked_from_project}->{name_with_namespace}\n";
+ }
+ print "\n";
+ }
+ return 0;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/search_user.pm b/lib/Devscripts/Salsa/search_user.pm
new file mode 100644
index 0000000..5ae3379
--- /dev/null
+++ b/lib/Devscripts/Salsa/search_user.pm
@@ -0,0 +1,36 @@
+# Searches users using given string
+package Devscripts::Salsa::search_user; # search_users
+
+use strict;
+use Devscripts::Output;
+use Moo::Role;
+
+sub search_user {
+ my ($self, $user) = @_;
+ unless ($user) {
+ ds_warn "User name is missing";
+ return 1;
+ }
+ my $users = $self->api->user($user);
+ if ($users) {
+ $users = [$users];
+ } else {
+ $users = $self->api->paginator('users', { search => $user })->all();
+ }
+ unless ($users and @$users) {
+ ds_warn "No user found";
+ return 1;
+ }
+ foreach (@$users) {
+ print <<END;
+Id : $_->{id}
+Username : $_->{username}
+Name : $_->{name}
+State : $_->{state}
+
+END
+ }
+ return 0;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/update_repo.pm b/lib/Devscripts/Salsa/update_repo.pm
new file mode 100755
index 0000000..6615d79
--- /dev/null
+++ b/lib/Devscripts/Salsa/update_repo.pm
@@ -0,0 +1,137 @@
+# Updates projects
+package Devscripts::Salsa::update_repo; # update_projects
+
+use strict;
+use Devscripts::Output;
+use GitLab::API::v4::Constants qw(:all);
+use Moo::Role;
+
+with "Devscripts::Salsa::Repo";
+
+our $prompt = 1;
+
+sub update_repo {
+ my ($self, @reponames) = @_;
+ if ($ds_yes < 0 and $self->config->command eq 'update_repo') {
+ ds_warn
+"update_projects can't be launched when --info is set, use update_safe";
+ return 1;
+ }
+ unless (@reponames or $self->config->all or $self->config->all_archived) {
+ ds_warn "Usage $0 update_projects <--all|--all-archived|names>";
+ return 1;
+ }
+ if (@reponames and $self->config->all) {
+ ds_warn "--all with a project name makes no sense";
+ return 1;
+ }
+ if (@reponames and $self->config->all_archived) {
+ ds_warn "--all-archived with a project name makes no sense";
+ return 1;
+ }
+ return $self->_update_repo(@reponames);
+}
+
+sub _update_repo {
+ my ($self, @reponames) = @_;
+ my $res = 0;
+ # Common options
+ my $configparams = {};
+ # visibility can be modified only by group owners
+ $configparams->{visibility} = 'public'
+ if $self->access_level >= $GITLAB_ACCESS_LEVEL_OWNER;
+ # get project list using Devscripts::Salsa::Repo
+ my @repos = $self->get_repo($prompt, @reponames);
+ return @repos unless (ref $repos[0]); # get_repo returns 1 when fails
+ foreach my $repo (@repos) {
+ my $id = $repo->[0];
+ my $str = $repo->[1];
+ ds_verbose "Configuring $str";
+ eval {
+ # apply new parameters
+ $self->api->edit_project($id,
+ { %$configparams, $self->desc($str) });
+ # Set project avatar
+ my @avatar_file = $self->desc_multipart($str);
+ $self->api->edit_project_multipart($id, {@avatar_file})
+ if (@avatar_file and $self->config->avatar_path);
+ # add hooks if needed
+ $str =~ s#^.*/##;
+ $self->add_hooks($id, $str);
+ };
+ if ($@) {
+ ds_warn "update_projects has failed for $str\n";
+ ds_verbose $@;
+ $res++;
+ unless ($self->config->no_fail) {
+ ds_verbose "Use --no-fail to continue";
+ return 1;
+ }
+ next;
+ } elsif ($self->config->rename_head) {
+ # 1 - creates new branch if --rename-head
+ my $project = $self->api->project($id);
+ if ($project->{default_branch} ne $self->config->dest_branch) {
+ eval {
+ $self->api->create_branch(
+ $id,
+ {
+ ref => $self->config->source_branch,
+ branch => $self->config->dest_branch,
+ });
+ };
+ if ($@) {
+ ds_debug $@ if ($@);
+ $project = undef;
+ }
+
+ eval {
+ $self->api->edit_project($id,
+ { default_branch => $self->config->dest_branch });
+ # delete old branch only if "create_branch" succeed
+ if ($project) {
+ $self->api->delete_branch($id,
+ $self->config->source_branch);
+ }
+ };
+ if ($@) {
+ ds_warn "Branch rename has failed for $str\n";
+ ds_verbose $@;
+ $res++;
+ unless ($self->config->no_fail) {
+ ds_verbose "Use --no-fail to continue";
+ return 1;
+ }
+ next;
+ }
+ } else {
+ ds_verbose "Head already renamed for $str";
+ }
+ }
+ ds_verbose "Project $str updated";
+ }
+ return $res;
+}
+
+sub access_level {
+ my ($self) = @_;
+ my $user_id = $self->api->current_user()->{id};
+ if ($self->group_id) {
+ my $tmp = $self->api->all_group_members($self->group_id,
+ { user_ids => $user_id });
+ unless ($tmp) {
+ my $members
+ = $self->api->paginator('all_group_members', $self->group_id,
+ { query => $user_id });
+ while ($_ = $members->next) {
+ return $_->{access_level} if ($_->{id} eq $user_id);
+ }
+ ds_warn "You're not member of this group";
+ return 0;
+ }
+ return $tmp->[0]->{access_level};
+ }
+ return $GITLAB_ACCESS_LEVEL_OWNER;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/update_safe.pm b/lib/Devscripts/Salsa/update_safe.pm
new file mode 100644
index 0000000..f022db7
--- /dev/null
+++ b/lib/Devscripts/Salsa/update_safe.pm
@@ -0,0 +1,22 @@
+# launches check_projects and launch update_projects if user agrees with this changes
+package Devscripts::Salsa::update_safe;
+
+use strict;
+use Devscripts::Output;
+use Moo::Role;
+
+with 'Devscripts::Salsa::check_repo'; # check_projects
+with 'Devscripts::Salsa::update_repo'; # update_projects
+
+sub update_safe {
+ my $self = shift;
+ my ($res, $fails) = $self->_check_repo(@_);
+ return 0 unless ($res);
+ return $res
+ if (ds_prompt("$res projects misconfigured, update them ? (Y/n) ")
+ =~ refuse);
+ $Devscripts::Salsa::update_repo::prompt = 0;
+ return $self->_update_repo(@$fails);
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/update_user.pm b/lib/Devscripts/Salsa/update_user.pm
new file mode 100644
index 0000000..f7dfeba
--- /dev/null
+++ b/lib/Devscripts/Salsa/update_user.pm
@@ -0,0 +1,38 @@
+# Updates user role in a group
+package Devscripts::Salsa::update_user;
+
+use strict;
+use Devscripts::Output;
+use Moo::Role;
+
+sub update_user {
+ my ($self, $level, $user) = @_;
+ unless ($level and $user) {
+ ds_warn "Usage $0 update_user <level> <userid>";
+ return 1;
+ }
+ unless ($self->group_id) {
+ ds_warn "Unable to update user without --group-id";
+ return 1;
+ }
+
+ my $id = $self->username2id($user);
+ my $al = $self->levels_name($level);
+ return 1
+ if (
+ $ds_yes < 0
+ and ds_prompt(
+"You're going to accept $user as $level in group $self->{group_id}. Continue (Y/n) "
+ ) =~ refuse
+ );
+ $self->api->update_group_member(
+ $self->group_id,
+ $id,
+ {
+ access_level => $al,
+ });
+ ds_warn "User $user removed from group " . $self->group_id;
+ return 0;
+}
+
+1;
diff --git a/lib/Devscripts/Salsa/whoami.pm b/lib/Devscripts/Salsa/whoami.pm
new file mode 100644
index 0000000..176e591
--- /dev/null
+++ b/lib/Devscripts/Salsa/whoami.pm
@@ -0,0 +1,24 @@
+# Gives information on token owner
+package Devscripts::Salsa::whoami;
+
+use strict;
+use Devscripts::Output;
+use Moo::Role;
+
+sub whoami {
+ my ($self) = @_;
+ my $current_user = $self->api->current_user;
+ print <<END;
+Id : $current_user->{id}
+Username: $current_user->{username}
+Name : $current_user->{name}
+Email : $current_user->{email}
+State : $current_user->{state}
+END
+ $self->cache->{user}->{ $current_user->{id} } = $current_user->{username};
+ $self->cache->{user_id}->{ $current_user->{username} }
+ = $current_user->{id};
+ return 0;
+}
+
+1;
diff --git a/lib/Devscripts/Set.pm b/lib/Devscripts/Set.pm
new file mode 100644
index 0000000..9ad4e1f
--- /dev/null
+++ b/lib/Devscripts/Set.pm
@@ -0,0 +1,126 @@
+# Copyright Bill Allombert <ballombe@debian.org> 2001.
+# Modifications copyright 2002 Julian Gilbey <jdg@debian.org>
+
+# 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 2 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 <https://www.gnu.org/licenses/>.
+
+package Devscripts::Set;
+
+use strict;
+
+BEGIN {
+ use Exporter ();
+ use vars qw(@EXPORT @ISA %EXPORT_TAGS);
+ @EXPORT = qw(SetMinus SetInter SetUnion);
+ @ISA = qw(Exporter);
+ %EXPORT_TAGS = ();
+}
+
+# Several routines to work with arrays whose elements are unique
+# (here called sets)
+
+=head1 NAME
+
+Devscripts::Set - Functions for handling sets.
+
+=head1 SYNOPSIS
+
+use Devscripts::Set;
+
+@set=ListToSet(@list);
+
+@setdiff=SetMinus(\@set1,\@set2);
+
+@setinter=SetInter(\@set1,\@set2);
+
+@setunion=SetUnion(\@set1,\@set2);
+
+=head1 DESCRIPTION
+
+ListToSet: Make a set (array with duplicates removed) from a list of
+items given by an array.
+
+SetMinus, SetInter, SetUnion: Compute the set theoretic difference,
+intersection, union of two sets given as arrays.
+
+=cut
+
+# Transforms a list to a set, removing duplicates
+# input: list
+# output: set
+
+sub ListToSet (@) {
+ my %items;
+
+ grep $items{$_}++, @_;
+
+ return keys %items;
+}
+
+# Compute the set-theoretic difference of two sets.
+# input: ref to Set 1, ref to Set 2
+# output: set
+
+sub SetMinus ($$) {
+ my ($set1, $set2) = @_;
+ my %items;
+
+ grep $items{$_}++, @$set1;
+ grep $items{$_}--, @$set2;
+
+ return grep $items{$_} > 0, keys %items;
+}
+
+# Compute the set-theoretic intersection of two sets.
+# input: ref to Set 1, ref to Set 2
+# output: set
+
+sub SetInter ($$) {
+ my ($set1, $set2) = @_;
+ my %items;
+
+ grep $items{$_}++, @$set1;
+ grep $items{$_}++, @$set2;
+
+ return grep $items{$_} == 2, keys %items;
+}
+
+#Compute the set-theoretic union of two sets.
+#input: ref to Set 1, ref to Set 2
+#output: set
+
+sub SetUnion ($$) {
+ my ($set1, $set2) = @_;
+ my %items;
+
+ grep $items{$_}++, @$set1;
+ grep $items{$_}++, @$set2;
+
+ return grep $items{$_} > 0, keys %items;
+}
+
+1;
+
+=head1 AUTHOR
+
+Bill Allombert <ballombe@debian.org>
+
+=head1 COPYING
+
+Copyright 2001 Bill Allombert <ballombe@debian.org>
+Modifications Copyright 2002 Julian Gilbey <jdg@debian.org>
+dpkg-depcheck is free software, covered by the GNU General Public License, and
+you are welcome to change it and/or distribute copies of it under
+certain conditions. There is absolutely no warranty for dpkg-depcheck.
+
+=cut
diff --git a/lib/Devscripts/Uscan/CatchRedirections.pm b/lib/Devscripts/Uscan/CatchRedirections.pm
new file mode 100644
index 0000000..28f99ca
--- /dev/null
+++ b/lib/Devscripts/Uscan/CatchRedirections.pm
@@ -0,0 +1,27 @@
+# dummy subclass used to store all the redirections for later use
+package Devscripts::Uscan::CatchRedirections;
+
+use base 'LWP::UserAgent';
+
+my @uscan_redirections;
+
+sub redirect_ok {
+ my $self = shift;
+ my ($request) = @_;
+ if ($self->SUPER::redirect_ok(@_)) {
+ push @uscan_redirections, $request->uri;
+ return 1;
+ }
+ return 0;
+}
+
+sub get_redirections {
+ return \@uscan_redirections;
+}
+
+sub clear_redirections {
+ undef @uscan_redirections;
+ return;
+}
+
+1;
diff --git a/lib/Devscripts/Uscan/Config.pm b/lib/Devscripts/Uscan/Config.pm
new file mode 100644
index 0000000..5ea97e1
--- /dev/null
+++ b/lib/Devscripts/Uscan/Config.pm
@@ -0,0 +1,392 @@
+
+=head1 NAME
+
+Devscripts::Uscan::Config - uscan configuration object
+
+=head1 SYNOPSIS
+
+ use Devscripts::Uscan::Config;
+ my $config = Devscripts::Uscan::Config->new->parse;
+
+=head1 DESCRIPTION
+
+Uscan configuration object. It can scan configuration files
+(B</etc/devscripts.conf> and B<~/.devscripts>) and command line arguments.
+
+=cut
+
+package Devscripts::Uscan::Config;
+
+use strict;
+
+use Devscripts::Uscan::Output;
+use Exporter 'import';
+use Moo;
+
+extends 'Devscripts::Config';
+
+our $CURRENT_WATCHFILE_VERSION = 4;
+
+use constant default_user_agent => "Debian uscan"
+ . ($main::uscan_version ? " $main::uscan_version" : '');
+
+our @EXPORT = (qw($CURRENT_WATCHFILE_VERSION));
+
+# I - ACCESSORS
+
+# Options + default values
+
+has bare => (is => 'rw');
+has check_dirname_level => (is => 'rw');
+has check_dirname_regex => (is => 'rw');
+has compression => (is => 'rw');
+has copyright_file => (is => 'rw');
+has destdir => (is => 'rw');
+has download => (is => 'rw');
+has download_current_version => (is => 'rw');
+has download_debversion => (is => 'rw');
+has download_version => (is => 'rw');
+has exclusion => (is => 'rw');
+has log => (is => 'rw');
+has orig => (is => 'rw');
+has package => (is => 'rw');
+has pasv => (is => 'rw');
+has http_header => (is => 'rw', default => sub { {} });
+
+# repack to .tar.$zsuffix if 1
+has repack => (is => 'rw');
+has safe => (is => 'rw');
+has signature => (is => 'rw');
+has symlink => (is => 'rw');
+has timeout => (is => 'rw');
+has user_agent => (is => 'rw');
+has uversion => (is => 'rw');
+has watchfile => (is => 'rw');
+
+# II - Options
+
+use constant keys => [
+ # 2.1 - Simple parameters that can be set in ~/.devscripts and command line
+ [
+ 'check-dirname-level=s', 'DEVSCRIPTS_CHECK_DIRNAME_LEVEL',
+ qr/^[012]$/, 1
+ ],
+ [
+ 'check-dirname-regex=s', 'DEVSCRIPTS_CHECK_DIRNAME_REGEX',
+ undef, 'PACKAGE(-.+)?'
+ ],
+ ['dehs!', 'USCAN_DEHS_OUTPUT', sub { $dehs = $_[1]; 1 }],
+ [
+ 'destdir=s',
+ 'USCAN_DESTDIR',
+ sub {
+ if (-d $_[1]) {
+ $_[0]->destdir($_[1]) if (-d $_[1]);
+ return 1;
+ }
+ return (0,
+ "The directory to store downloaded files(\$destdir): $_[1]");
+ },
+ '..'
+ ],
+ ['exclusion!', 'USCAN_EXCLUSION', 'bool', 1],
+ ['timeout=i', 'USCAN_TIMEOUT', qr/^\d+$/, 20],
+ [
+ 'user-agent|useragent=s',
+ 'USCAN_USER_AGENT',
+ qr/\w/,
+ sub {
+ default_user_agent;
+ }
+ ],
+ ['repack', 'USCAN_REPACK', 'bool'],
+ # 2.2 - Simple command line args
+ ['bare', undef, 'bool', 0],
+ ['compression=s'],
+ ['copyright-file=s'],
+ ['download-current-version', undef, 'bool'],
+ ['download-version=s'],
+ ['download-debversion|dversion=s'],
+ ['log', undef, 'bool'],
+ ['package=s'],
+ ['uversion|upstream-version=s'],
+ ['watchfile=s'],
+ # 2.3 - More complex options
+ # http headers (#955268)
+ ['http-header=s', 'USCAN_HTTP_HEADER', undef, sub { {} }],
+
+ # "download" and its aliases
+ [
+ undef,
+ 'USCAN_DOWNLOAD',
+ sub {
+ return (1, 'Bad USCAN_DOWNLOAD value, skipping')
+ unless ($_[1] =~ /^(?:yes|(no))$/i);
+ $_[0]->download(0) if $1;
+ return 1;
+ }
+ ],
+ [
+ 'download|d+',
+ undef,
+ sub {
+ $_[1] =~ s/^yes$/1/i;
+ $_[1] =~ s/^no$/0/i;
+ return (0, "Wrong number of -d")
+ unless ($_[1] =~ /^[0123]$/);
+ $_[0]->download($_[1]);
+ return 1;
+ },
+ 1
+ ],
+ [
+ 'force-download',
+ undef,
+ sub {
+ $_[0]->download(2);
+ }
+ ],
+ ['no-download', undef, sub { $_[0]->download(0); return 1; }],
+ ['overwrite-download', undef, sub { $_[0]->download(3) }],
+
+ # "pasv"
+ [
+ 'pasv|passive',
+ 'USCAN_PASV',
+ sub {
+ return $_[0]->pasv('default')
+ unless ($_[1] =~ /^(yes|0|1|no)$/);
+ $_[0]->pasv({
+ yes => 1,
+ 1 => 1,
+ no => 0,
+ 0 => 0,
+ }->{$1});
+ return 1;
+ },
+ 0
+ ],
+
+ # "safe" and "symlink" and their aliases
+ ['safe|report', 'USCAN_SAFE', 'bool', 0],
+ [
+ 'report-status',
+ undef,
+ sub {
+ $_[0]->safe(1);
+ $verbose ||= 1;
+ }
+ ],
+ ['copy', undef, sub { $_[0]->symlink('copy') }],
+ ['rename', undef, sub { $_[0]->symlink('rename') if ($_[1]); 1; }],
+ [
+ 'symlink!',
+ 'USCAN_SYMLINK',
+ sub {
+ $_[0]->symlink(
+ $_[1] =~ /^(no|0|rename)$/ ? $1
+ : $_[1] =~ /^(yes|1|symlink)$/ ? 'symlink'
+ : 'no'
+ );
+ return 1;
+ },
+ 'symlink'
+ ],
+ # "signature" and its aliases
+ ['signature!', undef, 'bool', 1],
+ ['skipsignature|skip-signature', undef, sub { $_[0]->signature(-1) }],
+ # "verbose" and its aliases
+ ['debug', undef, sub { $verbose = 2 }],
+ ['extra-debug', undef, sub { $verbose = 3 }],
+ ['no-verbose', undef, sub { $verbose = 0; return 1; }],
+ [
+ 'verbose|v+',
+ 'USCAN_VERBOSE',
+ sub {
+ $verbose = ($_[1] =~ /^yes$/i ? 1 : $_[1] =~ /^(\d)$/ ? $1 : 0);
+ return 1;
+ }
+ ],
+ # Display version
+ [
+ 'version',
+ undef,
+ sub {
+ if ($_[1]) { $_[0]->version; exit 0 }
+ }
+ ]];
+
+use constant rules => [
+ sub {
+ my $self = shift;
+ if ($self->package) {
+ $self->download(0)
+ unless ($self->download > 1); # compatibility
+ return (0,
+"The --package option requires to set the --watchfile option, too."
+ ) unless defined $self->watchfile;
+ }
+ $self->download(0) if ($self->safe == 1 and $self->download == 1);
+ return 1;
+ },
+ # $signature: -1 = no downloading signature and no verifying signature,
+ # 0 = no downloading signature but verifying signature,
+ # 1 = downloading signature and verifying signature
+ sub {
+ my $self = shift;
+ $self->signature(-1)
+ if $self->download == 0; # Change default 1 -> -1
+ return 1;
+ },
+ sub {
+ if (defined $_[0]->watchfile and @ARGV) {
+ return (0, "Can't have directory arguments if using --watchfile");
+ }
+ return 1;
+ },
+];
+
+# help methods
+sub usage {
+ my ($self) = @_;
+ print <<"EOF";
+Usage: $progname [options] [dir ...]
+ Process watch files in all .../debian/ subdirs of those listed (or the
+ current directory if none listed) to check for upstream releases.
+Options:
+ --no-conf, --noconf
+ Don\'t read devscripts config files;
+ must be the first option given
+ --no-verbose Don\'t report verbose information.
+ --verbose, -v Report verbose information.
+ --debug, -vv Report verbose information including the downloaded
+ web pages as processed to STDERR for debugging.
+ --extra-debug, -vvv Report also remote content during "search" step
+ --dehs Send DEHS style output (XML-type) to STDOUT, while
+ send all other uscan output to STDERR.
+ --no-dehs Use only traditional uscan output format (default)
+ --download, -d
+ Download the new upstream release (default)
+ --force-download, -dd
+ Download the new upstream release, even if up-to-date
+ (may not overwrite the local file)
+ --overwrite-download, -ddd
+ Download the new upstream release, even if up-to-date
+ (may overwrite the local file)
+ --no-download, --nodownload
+ Don\'t download and report information.
+ Previously downloaded tarballs may be used.
+ Change default to --skip-signature.
+ --signature Download signature and verify (default)
+ --no-signature Don\'t download signature but verify if already downloaded.
+ --skip-signature
+ Don\'t bother download signature nor verify it.
+ --safe, --report
+ avoid running unsafe scripts by skipping both the repacking
+ of the downloaded package and the updating of the new
+ source tree. Change default to --no-download and
+ --skip-signature.
+ --report-status (= --safe --verbose)
+ --download-version VERSION
+ Specify the version which the upstream release must
+ match in order to be considered, rather than using the
+ release with the highest version
+ --download-debversion VERSION
+ Specify the Debian package version to download the
+ corresponding upstream release version. The
+ dversionmangle and uversionmangle rules are
+ considered.
+ --download-current-version
+ Download the currently packaged version
+ --check-dirname-level N
+ Check parent directory name?
+ N=0 never check parent directory name
+ N=1 only when $progname changes directory (default)
+ N=2 always check parent directory name
+ --check-dirname-regex REGEX
+ What constitutes a matching directory name; REGEX is
+ a Perl regular expression; the string \`PACKAGE\' will
+ be replaced by the package name; see manpage for details
+ (default: 'PACKAGE(-.+)?')
+ --destdir Path of directory to which to download.
+ --package PACKAGE
+ Specify the package name rather than examining
+ debian/changelog; must use --upstream-version and
+ --watchfile with this option, no directory traversing
+ will be performed, no actions (even downloading) will be
+ carried out
+ --upstream-version VERSION
+ Specify the current upstream version in use rather than
+ parsing debian/changelog to determine this
+ --watchfile FILE
+ Specify the watch file rather than using debian/watch;
+ no directory traversing will be done in this case
+ --bare Disable all site specific special case codes to perform URL
+ redirections and page content alterations.
+ --no-exclusion Disable automatic exclusion of files mentioned in
+ debian/copyright field Files-Excluded and Files-Excluded-*
+ --pasv Use PASV mode for FTP connections
+ --no-pasv Don\'t use PASV mode for FTP connections (default)
+ --no-symlink Don\'t rename nor repack upstream tarball
+ --timeout N Specifies how much time, in seconds, we give remote
+ servers to respond (default 20 seconds)
+ --user-agent, --useragent
+ Override the default user agent string
+ --log Record md5sum changes of repackaging
+ --help Show this message
+ --version Show version information
+
+Options passed on to mk-origtargz:
+ --symlink Create a correctly named symlink to downloaded file (default)
+ --rename Rename instead of symlinking
+ --copy Copy instead of symlinking
+ --repack Repack downloaded archives to change compression
+ --compression [ gzip | bzip2 | lzma | xz ]
+ When the upstream sources are repacked, use compression COMP
+ for the resulting tarball (default: gzip)
+ --copyright-file FILE
+ Remove files matching the patterns found in FILE
+
+Default settings modified by devscripts configuration files:
+$self->{modified_conf_msg}
+EOF
+}
+
+sub version {
+ print <<"EOF";
+This is $progname, from the Debian devscripts package, version $main::uscan_version
+This code is copyright 1999-2006 by Julian Gilbey and 2018 by Xavier Guimard,
+all rights reserved.
+Original code by Christoph Lameter.
+This program comes with ABSOLUTELY NO WARRANTY.
+You are free to redistribute this code under the terms of the
+GNU General Public License, version 2 or later.
+EOF
+}
+
+1;
+__END__
+=head1 SEE ALSO
+
+L<uscan>, L<Devscripts::Config>
+
+=head1 AUTHOR
+
+B<uscan> was originally written by Christoph Lameter
+E<lt>clameter@debian.orgE<gt> (I believe), modified by Julian Gilbey
+E<lt>jdg@debian.orgE<gt>. HTTP support was added by Piotr Roszatycki
+E<lt>dexter@debian.orgE<gt>. B<uscan> was rewritten in Perl by Julian Gilbey.
+Xavier Guimard E<lt>yadd@debian.orgE<gt> rewrote uscan in object
+oriented Perl.
+
+=head1 COPYRIGHT AND LICENSE
+
+Copyright 2002-2006 by Julian Gilbey <jdg@debian.org>,
+2018 by Xavier Guimard <yadd@debian.org>
+
+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 2 of the License, or
+(at your option) any later version.
+
+=cut
diff --git a/lib/Devscripts/Uscan/Ctype/nodejs.pm b/lib/Devscripts/Uscan/Ctype/nodejs.pm
new file mode 100644
index 0000000..6a89063
--- /dev/null
+++ b/lib/Devscripts/Uscan/Ctype/nodejs.pm
@@ -0,0 +1,36 @@
+package Devscripts::Uscan::Ctype::nodejs;
+
+use strict;
+
+use Moo;
+use JSON;
+use Devscripts::Uscan::Output;
+
+has dir => (is => 'ro');
+has pkg => (
+ is => 'rw',
+ lazy => 1,
+ default => sub {
+ $_[0]->{dir} . '/package.json';
+ });
+
+sub version {
+ my ($self) = @_;
+ return unless $self->dir and -d $self->dir;
+ unless (-r $self->pkg) {
+ uscan_warn "Unable to read $self->{pkg}, skipping current version";
+ return;
+ }
+ my ($version, $content);
+ {
+ local $/ = undef;
+ open my $f, $self->pkg;
+ $content = <$f>;
+ close $f;
+ }
+ eval { $version = decode_json($content)->{version}; };
+ uscan_warn $@ if $@;
+ return $version;
+}
+
+1;
diff --git a/lib/Devscripts/Uscan/Ctype/perl.pm b/lib/Devscripts/Uscan/Ctype/perl.pm
new file mode 100644
index 0000000..ea06cfb
--- /dev/null
+++ b/lib/Devscripts/Uscan/Ctype/perl.pm
@@ -0,0 +1,36 @@
+package Devscripts::Uscan::Ctype::perl;
+
+use strict;
+
+use Moo;
+use JSON;
+use Devscripts::Uscan::Output;
+
+has dir => (is => 'ro');
+has pkg => (
+ is => 'rw',
+ lazy => 1,
+ default => sub {
+ $_[0]->{dir} . '/META.json';
+ });
+
+sub version {
+ my ($self) = @_;
+ return unless $self->dir and -d $self->dir;
+ unless (-r $self->pkg) {
+ uscan_warn "Unable to read $self->{pkg}, skipping current version";
+ return;
+ }
+ my ($version, $content);
+ {
+ local $/ = undef;
+ open my $f, $self->pkg;
+ $content = <$f>;
+ close $f;
+ }
+ eval { $version = decode_json($content)->{version}; };
+ uscan_warn $@ if $@;
+ return $version;
+}
+
+1;
diff --git a/lib/Devscripts/Uscan/Downloader.pm b/lib/Devscripts/Uscan/Downloader.pm
new file mode 100644
index 0000000..292884f
--- /dev/null
+++ b/lib/Devscripts/Uscan/Downloader.pm
@@ -0,0 +1,303 @@
+package Devscripts::Uscan::Downloader;
+
+use strict;
+use Cwd qw/cwd abs_path/;
+use Devscripts::Uscan::CatchRedirections;
+use Devscripts::Uscan::Output;
+use Devscripts::Uscan::Utils;
+use Dpkg::IPC;
+use File::DirList;
+use File::Find;
+use File::Temp qw/tempdir/;
+use File::Touch;
+use Moo;
+use URI;
+
+our $haveSSL;
+
+has git_upstream => (is => 'rw');
+
+BEGIN {
+ eval { require LWP::UserAgent; };
+ if ($@) {
+ my $progname = basename($0);
+ if ($@ =~ /^Can\'t locate LWP\/UserAgent\.pm/) {
+ die "$progname: you must have the libwww-perl package installed\n"
+ . "to use this script";
+ } else {
+ die "$progname: problem loading the LWP::UserAgent module:\n $@\n"
+ . "Have you installed the libwww-perl package?";
+ }
+ }
+ eval { require LWP::Protocol::https; };
+ $haveSSL = $@ ? 0 : 1;
+}
+
+has agent =>
+ (is => 'rw', default => sub { "Debian uscan $main::uscan_version" });
+has timeout => (is => 'rw');
+has pasv => (
+ is => 'rw',
+ default => 'default',
+ trigger => sub {
+ my ($self, $nv) = @_;
+ if ($nv) {
+ uscan_verbose "Set passive mode: $self->{pasv}";
+ $ENV{'FTP_PASSIVE'} = $self->pasv;
+ } elsif ($ENV{'FTP_PASSIVE'}) {
+ uscan_verbose "Unset passive mode";
+ delete $ENV{'FTP_PASSIVE'};
+ }
+ });
+has destdir => (is => 'rw');
+
+# 0: no repo, 1: shallow clone, 2: full clone
+has gitrepo_state => (
+ is => 'rw',
+ default => sub { 0 });
+has git_export_all => (
+ is => 'rw',
+ default => sub { 0 });
+has user_agent => (
+ is => 'rw',
+ lazy => 1,
+ default => sub {
+ my ($self) = @_;
+ my $user_agent
+ = Devscripts::Uscan::CatchRedirections->new(env_proxy => 1);
+ $user_agent->timeout($self->timeout);
+ $user_agent->agent($self->agent);
+
+ # Strip Referer header for Sourceforge to avoid SF sending back a
+ # "200 OK" with a <meta refresh=...> redirect
+ $user_agent->add_handler(
+ 'request_prepare' => sub {
+ my ($request, $ua, $h) = @_;
+ $request->remove_header('Referer');
+ },
+ m_hostname => 'sourceforge.net',
+ );
+ $self->{user_agent} = $user_agent;
+ });
+
+has ssl => (is => 'rw', default => sub { $haveSSL });
+
+has headers => (
+ is => 'ro',
+ default => sub { {} });
+
+sub download ($$$$$$$$) {
+ my (
+ $self, $url, $fname, $optref, $base,
+ $pkg_dir, $pkg, $mode, $gitrepo_dir
+ ) = @_;
+ my ($request, $response);
+ $mode ||= $optref->mode;
+ if ($mode eq 'http') {
+ if ($url =~ /^https/ and !$self->ssl) {
+ uscan_die "$progname: you must have the "
+ . "liblwp-protocol-https-perl package installed\n"
+ . "to use https URLs";
+ }
+
+ # substitute HTML entities
+ # Is anything else than "&amp;" required? I doubt it.
+ uscan_verbose "Requesting URL:\n $url";
+ my $headers = HTTP::Headers->new;
+ $headers->header('Accept' => '*/*');
+ $headers->header('Referer' => $base);
+ my $uri_o = URI->new($url);
+ foreach my $k (keys %{ $self->headers }) {
+ if ($k =~ /^(.*?)@(.*)$/) {
+ my $baseUrl = $1;
+ my $hdr = $2;
+ if ($url =~ m#^\Q$baseUrl\E(?:/.*)?$#) {
+ $headers->header($hdr => $self->headers->{$k});
+ uscan_verbose "Set per-host custom header $hdr for $url";
+ } else {
+ uscan_debug "$url does not start with $1";
+ }
+ } else {
+ uscan_warn "Malformed http-header: $k";
+ }
+ }
+ $request = HTTP::Request->new('GET', $url, $headers);
+ $response = $self->user_agent->request($request, $fname);
+ if (!$response->is_success) {
+ uscan_warn((defined $pkg_dir ? "In directory $pkg_dir, d" : "D")
+ . "ownloading\n $url failed: "
+ . $response->status_line);
+ return 0;
+ }
+ } elsif ($mode eq 'ftp') {
+ uscan_verbose "Requesting URL:\n $url";
+ $request = HTTP::Request->new('GET', "$url");
+ $response = $self->user_agent->request($request, $fname);
+ if (!$response->is_success) {
+ uscan_warn(
+ (defined $pkg_dir ? "In directory $pkg_dir, d" : "D")
+ . "ownloading\n $url failed: "
+ . $response->status_line);
+ return 0;
+ }
+ } else { # elsif ($$optref{'mode'} eq 'git')
+ my $destdir = $self->destdir;
+ my $curdir = cwd();
+ $fname =~ m%(.*)/$pkg-([^_/]*)\.tar\.(gz|xz|bz2|lzma|zstd?)%;
+ my $dst = $1;
+ my $abs_dst = abs_path($dst);
+ my $ver = $2;
+ my $suffix = $3;
+ my ($gitrepo, $gitref) = split /[[:space:]]+/, $url, 2;
+ my $clean = sub {
+ uscan_exec_no_fail('rm', '-fr', $gitrepo_dir);
+ };
+ my $clean_and_die = sub {
+ $clean->();
+ uscan_die @_;
+ };
+
+ if ($mode eq 'svn') {
+ my $tempdir = tempdir(CLEANUP => 1);
+ my $old_umask = umask(oct('022'));
+ uscan_exec('svn', 'export', $url, "$tempdir/$pkg-$ver");
+ umask($old_umask);
+ find({
+ wanted => sub {
+ return if !-d $File::Find::name;
+ my ($newest) = grep { $_ ne '.' && $_ ne '..' }
+ map { $_->[13] } @{ File::DirList::list($_, 'M') };
+ return if !$newest;
+ my $touch
+ = File::Touch->new(reference => $_ . '/' . $newest);
+ $touch->touch($_);
+ },
+ bydepth => 1,
+ no_chdir => 1,
+ },
+ "$tempdir/$pkg-$ver"
+ );
+ uscan_exec(
+ 'tar', '-C',
+ $tempdir, '--sort=name',
+ '--owner=root', '--group=root',
+ '-cvf', "$abs_dst/$pkg-$ver.tar",
+ "$pkg-$ver"
+ );
+ } elsif ($self->git_upstream) {
+ my ($infodir, $attr_file, $attr_bkp);
+ if ($self->git_export_all) {
+ # override any export-subst and export-ignore attributes
+ spawn(
+ exec => [qw|git rev-parse --git-path info/|],
+ to_string => \$infodir,
+ );
+ chomp $infodir;
+ mkdir $infodir unless -e $infodir;
+ spawn(
+ exec => [qw|git rev-parse --git-path info/attributes|],
+ to_string => \$attr_file,
+ );
+ chomp $attr_file;
+ spawn(
+ exec =>
+ [qw|git rev-parse --git-path info/attributes-uscan|],
+ to_string => \$attr_bkp,
+ );
+ chomp $attr_bkp;
+ rename $attr_file, $attr_bkp if -e $attr_file;
+ my $attr_fh;
+
+ unless (open($attr_fh, '>', $attr_file)) {
+ rename $attr_bkp, $attr_file if -e $attr_bkp;
+ uscan_die("could not open $attr_file for writing");
+ }
+ print $attr_fh "* -export-subst\n* -export-ignore\n";
+ close $attr_fh;
+ }
+
+ uscan_exec_no_fail('git', 'archive', '--format=tar',
+ "--prefix=$pkg-$ver/", "--output=$abs_dst/$pkg-$ver.tar",
+ $gitref) == 0
+ or $clean_and_die->("git archive failed");
+
+ if ($self->git_export_all) {
+ # restore attributes
+ if (-e $attr_bkp) {
+ rename $attr_bkp, $attr_file;
+ } else {
+ unlink $attr_file;
+ }
+ }
+ } else {
+ if ($self->gitrepo_state == 0) {
+ if ($optref->gitmode eq 'shallow') {
+ my $tag = $gitref;
+ $tag =~ s#^refs/(?:tags|heads)/##;
+ uscan_exec('git', 'clone', '--bare', '--depth=1', '-b',
+ $tag, $base, "$destdir/$gitrepo_dir");
+ $self->gitrepo_state(1);
+ } else {
+ uscan_exec('git', 'clone', '--bare', $base,
+ "$destdir/$gitrepo_dir");
+ $self->gitrepo_state(2);
+ }
+ }
+ if ($self->git_export_all) {
+ # override any export-subst and export-ignore attributes
+ my ($infodir, $attr_file);
+ spawn(
+ exec => [
+ 'git', "--git-dir=$destdir/$gitrepo_dir",
+ 'rev-parse', '--git-path', 'info/'
+ ],
+ to_string => \$infodir,
+ );
+ chomp $infodir;
+ mkdir $infodir unless -e $infodir;
+ spawn(
+ exec => [
+ 'git', "--git-dir=$destdir/$gitrepo_dir",
+ 'rev-parse', '--git-path',
+ 'info/attributes'
+ ],
+ to_string => \$attr_file,
+ );
+ chomp $attr_file;
+ my $attr_fh;
+ $clean_and_die->("could not open $attr_file for writing")
+ unless open($attr_fh, '>', $attr_file);
+ print $attr_fh "* -export-subst\n* -export-ignore\n";
+ close $attr_fh;
+ }
+
+ uscan_exec_no_fail(
+ 'git', "--git-dir=$destdir/$gitrepo_dir",
+ 'archive', '--format=tar',
+ "--prefix=$pkg-$ver/", "--output=$abs_dst/$pkg-$ver.tar",
+ $gitref
+ ) == 0
+ or $clean_and_die->("git archive failed");
+ }
+
+ chdir "$abs_dst" or $clean_and_die->("Unable to chdir($abs_dst): $!");
+ if ($suffix eq 'gz') {
+ uscan_exec("gzip", "-n", "-9", "$pkg-$ver.tar");
+ } elsif ($suffix eq 'xz') {
+ uscan_exec("xz", "$pkg-$ver.tar");
+ } elsif ($suffix eq 'bz2') {
+ uscan_exec("bzip2", "$pkg-$ver.tar");
+ } elsif ($suffix eq 'lzma') {
+ uscan_exec("lzma", "$pkg-$ver.tar");
+ #} elsif ($suffix =~ /^zstd?$/) {
+ # uscan_exec("zstd", "$pkg-$ver.tar");
+ } else {
+ $clean_and_die->("Unknown suffix file to repack: $suffix");
+ }
+ chdir "$curdir" or $clean_and_die->("Unable to chdir($curdir): $!");
+ $clean->();
+ }
+ return 1;
+}
+
+1;
diff --git a/lib/Devscripts/Uscan/FindFiles.pm b/lib/Devscripts/Uscan/FindFiles.pm
new file mode 100644
index 0000000..3f8f8b3
--- /dev/null
+++ b/lib/Devscripts/Uscan/FindFiles.pm
@@ -0,0 +1,257 @@
+
+=head1 NAME
+
+Devscripts::Uscan::FindFiles - watchfile finder
+
+=head1 SYNOPSIS
+
+ use Devscripts::Uscan::Config;
+ use Devscripts::Uscan::FindFiles;
+
+ # Get config
+ my $config = Devscripts::Uscan::Config->new->parse;
+
+ # Search watchfiles
+ my @wf = find_watch_files($config);
+
+=head1 DESCRIPTION
+
+This package exports B<find_watch_files()> function. This function search
+Debian watchfiles following configuration parameters.
+
+=head1 SEE ALSO
+
+L<uscan>, L<Devscripts::Uscan::WatchFile>, L<Devscripts::Uscan::Config>
+
+=head1 AUTHOR
+
+B<uscan> was originally written by Christoph Lameter
+E<lt>clameter@debian.orgE<gt> (I believe), modified by Julian Gilbey
+E<lt>jdg@debian.orgE<gt>. HTTP support was added by Piotr Roszatycki
+E<lt>dexter@debian.orgE<gt>. B<uscan> was rewritten in Perl by Julian Gilbey.
+Xavier Guimard E<lt>yadd@debian.orgE<gt> rewrote uscan in object
+oriented Perl.
+
+=head1 COPYRIGHT AND LICENSE
+
+Copyright 2002-2006 by Julian Gilbey <jdg@debian.org>,
+2018 by Xavier Guimard <yadd@debian.org>
+
+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 2 of the License, or
+(at your option) any later version.
+
+=cut
+
+package Devscripts::Uscan::FindFiles;
+
+use strict;
+use filetest 'access';
+use Cwd qw/cwd/;
+use Exporter 'import';
+use Devscripts::Uscan::Output;
+use Devscripts::Versort;
+use Dpkg::Changelog::Parse qw(changelog_parse);
+use File::Basename;
+
+our @EXPORT = ('find_watch_files');
+
+sub find_watch_files {
+ my ($config) = @_;
+ my $opwd = cwd();
+
+ # when --watchfile is used
+ if (defined $config->watchfile) {
+ uscan_verbose "Option --watchfile=$config->{watchfile} used";
+ my ($config) = (@_);
+
+ # no directory traversing then, and things are very simple
+ if (defined $config->package) {
+
+ # no need to even look for a changelog!
+ return (
+ ['.', $config->package, $config->uversion, $config->watchfile]
+ );
+ } else {
+ # Check for debian/changelog file
+ until (-r 'debian/changelog') {
+ chdir '..' or uscan_die "can't chdir ..: $!";
+ if (cwd() eq '/') {
+ uscan_die "Are you in the source code tree?\n"
+ . " Cannot find readable debian/changelog anywhere!";
+ }
+ }
+
+ my ($package, $debversion, $uversion)
+ = scan_changelog($config, $opwd, 1);
+
+ return ([cwd(), $package, $uversion, $config->watchfile]);
+ }
+ }
+
+ # when --watchfile is not used, scan watch files
+ push @ARGV, '.' if !@ARGV;
+ {
+ local $, = ',';
+ uscan_verbose "Scan watch files in @ARGV";
+ }
+
+ # Run find to find the directories. We will handle filenames with spaces
+ # correctly, which makes this code a little messier than it would be
+ # otherwise.
+ my @dirs;
+ open FIND, '-|', 'find', @ARGV,
+ qw{-follow -type d ( -name .git -prune -o -name debian -print ) }
+ or uscan_die "Couldn't exec find: $!";
+
+ while (<FIND>) {
+ chomp;
+ push @dirs, $_;
+ uscan_debug "Found $_";
+ }
+ close FIND;
+
+ uscan_die "No debian directories found" unless @dirs;
+
+ my @debdirs = ();
+
+ my $origdir = cwd;
+ for my $dir (@dirs) {
+ $dir =~ s%/debian$%%;
+
+ unless (chdir $origdir) {
+ uscan_warn "Couldn't chdir back to $origdir, skipping: $!";
+ next;
+ }
+ unless (chdir $dir) {
+ uscan_warn "Couldn't chdir $dir, skipping: $!";
+ next;
+ }
+
+ uscan_verbose "Check debian/watch and debian/changelog in $dir";
+
+ # Check for debian/watch file
+ if (-r 'debian/watch') {
+ unless (-r 'debian/changelog') {
+ uscan_warn
+ "Problems reading debian/changelog in $dir, skipping";
+ next;
+ }
+ my ($package, $debversion, $uversion)
+ = scan_changelog($config, $opwd);
+ next unless ($package);
+
+ uscan_verbose
+ "package=\"$package\" version=\"$uversion\" (no epoch/revision)";
+ push @debdirs, [$debversion, $dir, $package, $uversion];
+ }
+ }
+
+ uscan_warn "No watch file found" unless @debdirs;
+
+ # Was there a --upstream-version option?
+ if (defined $config->uversion) {
+ if (@debdirs == 1) {
+ $debdirs[0][3] = $config->uversion;
+ } else {
+ uscan_warn
+"ignoring --upstream-version as more than one debian/watch file found";
+ }
+ }
+
+ # Now sort the list of directories, so that we process the most recent
+ # directories first, as determined by the package version numbers
+ @debdirs = Devscripts::Versort::deb_versort(@debdirs);
+
+ # Now process the watch files in order. If a directory d has
+ # subdirectories d/sd1/debian and d/sd2/debian, which each contain watch
+ # files corresponding to the same package, then we only process the watch
+ # file in the package with the latest version number.
+ my %donepkgs;
+ my @results;
+ for my $debdir (@debdirs) {
+ shift @$debdir; # don't need the Debian version number any longer
+ my $dir = $$debdir[0];
+ my $parentdir = dirname($dir);
+ my $package = $$debdir[1];
+ my $version = $$debdir[2];
+
+ if (exists $donepkgs{$parentdir}{$package}) {
+ uscan_warn
+"Skipping $dir/debian/watch\n as this package has already been found";
+ next;
+ }
+
+ unless (chdir $origdir) {
+ uscan_warn "Couldn't chdir back to $origdir, skipping: $!";
+ next;
+ }
+ unless (chdir $dir) {
+ uscan_warn "Couldn't chdir $dir, skipping: $!";
+ next;
+ }
+
+ uscan_verbose
+"$dir/debian/changelog sets package=\"$package\" version=\"$version\"";
+ push @results, [$dir, $package, $version, "debian/watch", cwd];
+ }
+ unless (chdir $origdir) {
+ uscan_die "Couldn't chdir back to $origdir! $!";
+ }
+ return @results;
+}
+
+sub scan_changelog {
+ my ($config, $opwd, $die) = @_;
+ my $out
+ = $die
+ ? sub { uscan_die(@_) }
+ : sub { uscan_warn($_[0] . ', skipping'); return undef; };
+
+ # Figure out package info we need
+ my $changelog = eval { changelog_parse(); };
+ if ($@) {
+ return $out->("Problems parsing debian/changelog");
+ }
+
+ my ($package, $debversion, $uversion);
+ $package = $changelog->{Source};
+ return $out->("Problem determining the package name from debian/changelog")
+ unless defined $package;
+ $debversion = $changelog->{Version};
+ return $out->("Problem determining the version from debian/changelog")
+ unless defined $debversion;
+ uscan_verbose
+"package=\"$package\" version=\"$debversion\" (as seen in debian/changelog)";
+
+ # Check the directory is properly named for safety
+ if ($config->check_dirname_level == 2
+ or ($config->check_dirname_level == 1 and cwd() ne $opwd)) {
+ my $good_dirname;
+ my $re = $config->check_dirname_regex;
+ $re =~ s/PACKAGE/\Q$package\E/g;
+ if ($re =~ m%/%) {
+ $good_dirname = (cwd() =~ m%^$re$%);
+ } else {
+ $good_dirname = (basename(cwd()) =~ m%^$re$%);
+ }
+ return $out->("The directory name "
+ . basename(cwd())
+ . " doesn't match the requirement of\n"
+ . " --check-dirname-level=$config->{check_dirname_level} --check-dirname-regex=$re .\n"
+ . " Set --check-dirname-level=0 to disable this sanity check feature."
+ ) unless $good_dirname;
+ }
+
+ # Get current upstream version number
+ if (defined $config->uversion) {
+ $uversion = $config->uversion;
+ } else {
+ $uversion = $debversion;
+ $uversion =~ s/-[^-]+$//; # revision
+ $uversion =~ s/^\d+://; # epoch
+ }
+ return ($package, $debversion, $uversion);
+}
+1;
diff --git a/lib/Devscripts/Uscan/Keyring.pm b/lib/Devscripts/Uscan/Keyring.pm
new file mode 100644
index 0000000..0af6088
--- /dev/null
+++ b/lib/Devscripts/Uscan/Keyring.pm
@@ -0,0 +1,168 @@
+package Devscripts::Uscan::Keyring;
+
+use strict;
+use Devscripts::Uscan::Output;
+use Devscripts::Uscan::Utils;
+use Dpkg::IPC;
+use File::Copy qw/copy move/;
+use File::Which;
+use File::Path qw/make_path remove_tree/;
+use File::Temp qw/tempfile tempdir/;
+use List::Util qw/first/;
+
+sub new {
+ my ($class) = @_;
+ my $keyring;
+ my $havegpgv = first { which $_ } qw(gpgv2 gpgv);
+ my $havegpg = first { which $_ } qw(gpg2 gpg);
+ uscan_die("Please install gpgv or gpgv2.") unless defined $havegpgv;
+ uscan_die("Please install gnupg or gnupg2.") unless defined $havegpg;
+
+ # upstream/signing-key.pgp and upstream-signing-key.pgp are deprecated
+ # but supported
+ if (-r "debian/upstream/signing-key.asc") {
+ $keyring = "debian/upstream/signing-key.asc";
+ } else {
+ my $binkeyring = first { -r $_ } qw(
+ debian/upstream/signing-key.pgp
+ debian/upstream-signing-key.pgp
+ );
+ if (defined $binkeyring) {
+ make_path('debian/upstream', { mode => 0700, verbose => 'true' });
+
+ # convert to the policy complying armored key
+ uscan_verbose(
+ "Found upstream binary signing keyring: $binkeyring");
+
+ # Need to convert to an armored key
+ $keyring = "debian/upstream/signing-key.asc";
+ uscan_warn "Found deprecated binary keyring ($binkeyring). "
+ . "Please save it in armored format in $keyring. For example:\n"
+ . " gpg --output $keyring --enarmor $binkeyring";
+ spawn(
+ exec => [
+ $havegpg,
+ '--homedir' => "/dev/null",
+ '--no-options', '-q', '--batch', '--no-default-keyring',
+ '--output' => $keyring,
+ '--enarmor', $binkeyring
+ ],
+ wait_child => 1
+ );
+ uscan_warn("Generated upstream signing keyring: $keyring");
+ move $binkeyring, "$binkeyring.backup";
+ uscan_verbose(
+ "Renamed upstream binary signing keyring: $binkeyring.backup");
+ }
+ }
+
+ # Need to convert an armored key to binary for use by gpgv
+ my $gpghome;
+ if (defined $keyring) {
+ uscan_verbose("Found upstream signing keyring: $keyring");
+ if ($keyring =~ m/\.asc$/) { # always true
+ $gpghome = tempdir(CLEANUP => 1);
+ my $newkeyring = "$gpghome/trustedkeys.gpg";
+ spawn(
+ exec => [
+ $havegpg,
+ '--homedir' => $gpghome,
+ '--no-options', '-q', '--batch', '--no-default-keyring',
+ '--output' => $newkeyring,
+ '--dearmor', $keyring
+ ],
+ wait_child => 1
+ );
+ $keyring = $newkeyring;
+ }
+ }
+
+ # Return undef if not key found
+ else {
+ return undef;
+ }
+ my $self = bless {
+ keyring => $keyring,
+ gpghome => $gpghome,
+ gpgv => $havegpgv,
+ gpg => $havegpg,
+ }, $class;
+ return $self;
+}
+
+sub verify {
+ my ($self, $sigfile, $newfile) = @_;
+ uscan_verbose(
+ "Verifying OpenPGP self signature of $newfile and extract $sigfile");
+ unless (
+ uscan_exec_no_fail(
+ $self->{gpgv},
+ '--homedir' => $self->{gpghome},
+ '--keyring' => $self->{keyring},
+ '-o' => "$sigfile",
+ "$newfile"
+ ) >> 8 == 0
+ ) {
+ uscan_die("OpenPGP signature did not verify.");
+ }
+}
+
+sub verifyv {
+ my ($self, $sigfile, $base) = @_;
+ uscan_verbose("Verifying OpenPGP signature $sigfile for $base");
+ unless (
+ uscan_exec_no_fail(
+ $self->{gpgv},
+ '--homedir' => '/dev/null',
+ '--keyring' => $self->{keyring},
+ $sigfile, $base
+ ) >> 8 == 0
+ ) {
+ uscan_die("OpenPGP signature did not verify.");
+ }
+}
+
+sub verify_git {
+ my ($self, $gitdir, $tag, $git_upstream) = @_;
+ my $commit;
+ my @dir = $git_upstream ? () : ('--git-dir', $gitdir);
+ spawn(
+ exec => ['git', @dir, 'show-ref', $tag],
+ to_string => \$commit
+ );
+ uscan_die "git tag not found" unless ($commit);
+ $commit =~ s/\s.*$//;
+ chomp $commit;
+ my $file;
+ spawn(
+ exec => ['git', @dir, 'cat-file', '-p', $commit],
+ to_string => \$file
+ );
+ my $dir;
+ spawn(exec => ['mktemp', '-d'], to_string => \$dir);
+ chomp $dir;
+
+ unless ($file =~ /^(.*?\n)(\-+\s*BEGIN PGP SIGNATURE\s*\-+.*)$/s) {
+ uscan_die "Tag $tag is not signed";
+ }
+ open F, ">$dir/txt" or die $!;
+ open S, ">$dir/sig" or die $!;
+ print F $1;
+ print S $2;
+ close F;
+ close S;
+
+ unless (
+ uscan_exec_no_fail(
+ $self->{gpgv},
+ '--homedir' => $self->{gpghome},
+ '--keyring' => $self->{keyring},
+ "$dir/sig", "$dir/txt"
+ ) >> 8 == 0
+ ) {
+ uscan_die("OpenPGP signature did not verify.");
+ }
+ remove_tree($dir);
+}
+
+1;
diff --git a/lib/Devscripts/Uscan/Output.pm b/lib/Devscripts/Uscan/Output.pm
new file mode 100644
index 0000000..77126cf
--- /dev/null
+++ b/lib/Devscripts/Uscan/Output.pm
@@ -0,0 +1,129 @@
+package Devscripts::Uscan::Output;
+
+use strict;
+use Devscripts::Output;
+use Exporter 'import';
+use File::Basename;
+
+our @EXPORT = (
+ @Devscripts::Output::EXPORT, qw(
+ uscan_msg uscan_verbose dehs_verbose uscan_warn uscan_debug uscan_msg_raw
+ uscan_extra_debug uscan_die dehs_output $dehs $verbose $dehs_tags
+ $dehs_start_output $dehs_end_output $found
+ ));
+
+# ACCESSORS
+our ($dehs, $dehs_tags, $dehs_start_output, $dehs_end_output, $found)
+ = (0, {}, 0, 0);
+
+our $progname = basename($0);
+
+sub printwarn_raw {
+ my ($msg, $w) = @_;
+ if ($w or $dehs) {
+ print STDERR "$msg";
+ } else {
+ print "$msg";
+ }
+}
+
+sub printwarn {
+ my ($msg, $w) = @_;
+ chomp $msg;
+ printwarn_raw("$msg\n", $w);
+}
+
+sub uscan_msg_raw {
+ printwarn_raw($_[0]);
+}
+
+sub uscan_msg {
+ printwarn($_[0]);
+}
+
+sub uscan_verbose {
+ ds_verbose($_[0], $dehs);
+}
+
+sub uscan_debug {
+ ds_debug($_[0], $dehs);
+}
+
+sub uscan_extra_debug {
+ ds_extra_debug($_[0], $dehs);
+}
+
+sub dehs_verbose ($) {
+ my $msg = $_[0];
+ push @{ $dehs_tags->{'messages'} }, "$msg\n";
+ uscan_verbose($msg);
+}
+
+sub uscan_warn ($) {
+ my $msg = $_[0];
+ push @{ $dehs_tags->{'warnings'} }, $msg if $dehs;
+ printwarn("$progname warn: $msg" . &Devscripts::Output::who_called, 1);
+}
+
+sub uscan_die ($) {
+ my $msg = $_[0];
+ if ($dehs) {
+ $dehs_tags = { 'errors' => "$msg" };
+ $dehs_end_output = 1;
+ dehs_output();
+ }
+ $msg = "$progname die: $msg" . &Devscripts::Output::who_called;
+ if ($Devscripts::Output::die_on_error) {
+ die $msg;
+ }
+ printwarn($msg, 1);
+}
+
+sub dehs_output () {
+ return unless $dehs;
+
+ if (!$dehs_start_output) {
+ print "<dehs>\n";
+ $dehs_start_output = 1;
+ }
+
+ for my $tag (
+ qw(package debian-uversion debian-mangled-uversion
+ upstream-version upstream-url decoded-checksum
+ status target target-path messages warnings errors)
+ ) {
+ if (exists $dehs_tags->{$tag}) {
+ if (ref $dehs_tags->{$tag} eq "ARRAY") {
+ foreach my $entry (@{ $dehs_tags->{$tag} }) {
+ $entry =~ s/</&lt;/g;
+ $entry =~ s/>/&gt;/g;
+ $entry =~ s/&/&amp;/g;
+ print "<$tag>$entry</$tag>\n";
+ }
+ } else {
+ $dehs_tags->{$tag} =~ s/</&lt;/g;
+ $dehs_tags->{$tag} =~ s/>/&gt;/g;
+ $dehs_tags->{$tag} =~ s/&/&amp;/g;
+ print "<$tag>$dehs_tags->{$tag}</$tag>\n";
+ }
+ }
+ }
+ foreach my $cmp (@{ $dehs_tags->{'component-name'} }) {
+ print qq'<component id="$cmp">\n';
+ foreach my $tag (
+ qw(debian-uversion debian-mangled-uversion
+ upstream-version upstream-url target target-path)
+ ) {
+ my $v = shift @{ $dehs_tags->{"component-$tag"} };
+ print " <component-$tag>$v</component-$tag>\n" if $v;
+ }
+ print "</component>\n";
+ }
+ if ($dehs_end_output) {
+ print "</dehs>\n";
+ }
+
+ # Don't repeat output
+ $dehs_tags = {};
+}
+1;
diff --git a/lib/Devscripts/Uscan/Utils.pm b/lib/Devscripts/Uscan/Utils.pm
new file mode 100644
index 0000000..e93f240
--- /dev/null
+++ b/lib/Devscripts/Uscan/Utils.pm
@@ -0,0 +1,475 @@
+package Devscripts::Uscan::Utils;
+
+use strict;
+use Devscripts::Uscan::Output;
+use Devscripts::Utils;
+use Exporter 'import';
+
+our @EXPORT = (
+ qw(fix_href recursive_regex_dir newest_dir get_compression
+ get_suffix get_priority quoted_regex_parse safe_replace mangle
+ uscan_exec uscan_exec_no_fail)
+);
+
+#######################################################################
+# {{{ code 5: utility functions (download)
+#######################################################################
+sub fix_href ($) {
+ my ($href) = @_;
+
+ # Remove newline (code moved from outside fix_href)
+ $href =~ s/\n//g;
+
+ # Remove whitespace from URLs:
+ # https://www.w3.org/TR/html5/links.html#links-created-by-a-and-area-elements
+ $href =~ s/^\s+//;
+ $href =~ s/\s+$//;
+
+ return $href;
+}
+
+sub recursive_regex_dir ($$$$$$) {
+
+ # If return '', parent code to cause return 1
+ my ($line, $base, $dirversionmangle, $watchfile, $lineptr,
+ $download_version)
+ = @_;
+
+ $base =~ m%^(\w+://[^/]+)/(.*)$%;
+ my $site = $1;
+ my @dirs = ();
+ if (defined $2) {
+ @dirs = split /(\/)/, $2;
+ }
+ my $dir = '/';
+
+ foreach my $dirpattern (@dirs) {
+ if ($dirpattern =~ /\(.*\)/) {
+ uscan_verbose "dir=>$dir dirpattern=>$dirpattern";
+ my $newest_dir = newest_dir($line, $site, $dir, $dirpattern,
+ $dirversionmangle, $watchfile, $lineptr, $download_version);
+ uscan_verbose "newest_dir => '$newest_dir'";
+ if ($newest_dir ne '') {
+ $dir .= "$newest_dir";
+ } else {
+ uscan_debug "No \$newest_dir";
+ return '';
+ }
+ } else {
+ $dir .= "$dirpattern";
+ }
+ }
+ return $site . $dir;
+}
+
+# very similar to code above
+sub newest_dir ($$$$$$$$) {
+
+ # return string $newdir as success
+ # return string '' if error, to cause grand parent code to return 1
+ my ($line, $site, $dir, $pattern, $dirversionmangle, $watchfile,
+ $lineptr, $download_version)
+ = @_;
+ my ($newdir);
+ uscan_verbose "Requesting URL:\n $site$dir";
+ if ($site =~ m%^http(s)?://%) {
+ require Devscripts::Uscan::http;
+ $newdir = Devscripts::Uscan::http::http_newdir($1, @_);
+ } elsif ($site =~ m%^ftp://%) {
+ require Devscripts::Uscan::ftp;
+ $newdir = Devscripts::Uscan::ftp::ftp_newdir(@_);
+ } else {
+ # Neither HTTP nor FTP site
+ uscan_warn "neither HTTP nor FTP site, impossible case for newdir().";
+ $newdir = '';
+ }
+ return $newdir;
+}
+#######################################################################
+# }}} code 5: utility functions (download)
+#######################################################################
+
+#######################################################################
+# {{{ code 6: utility functions (compression)
+#######################################################################
+# Get legal values for compression
+sub get_compression ($) {
+ my $compression = $_[0];
+ my $canonical_compression;
+
+ # be liberal in what you accept...
+ my %opt2comp = (
+ gz => 'gzip',
+ gzip => 'gzip',
+ bz2 => 'bzip2',
+ bzip2 => 'bzip2',
+ lzma => 'lzma',
+ xz => 'xz',
+ zip => 'zip',
+ zst => 'zst',
+ zstd => 'zst',
+ );
+
+ # Normalize compression methods to the names used by Dpkg::Compression
+ if (exists $opt2comp{$compression}) {
+ $canonical_compression = $opt2comp{$compression};
+ } else {
+ uscan_die "$progname: invalid compression, $compression given.";
+ }
+ return $canonical_compression;
+}
+
+# Get legal values for compression suffix
+sub get_suffix ($) {
+ my $compression = $_[0];
+ my $canonical_suffix;
+
+ # be liberal in what you accept...
+ my %opt2suffix = (
+ gz => 'gz',
+ gzip => 'gz',
+ bz2 => 'bz2',
+ bzip2 => 'bz2',
+ lzma => 'lzma',
+ xz => 'xz',
+ zip => 'zip',
+ zst => 'zst',
+ zstd => 'zst',
+ );
+
+ # Normalize compression methods to the names used by Dpkg::Compression
+ if (exists $opt2suffix{$compression}) {
+ $canonical_suffix = $opt2suffix{$compression};
+ } elsif ($compression eq 'default') {
+ require Devscripts::MkOrigtargz::Config;
+ return &Devscripts::MkOrigtargz::Config::default_compression;
+ } else {
+ uscan_die "$progname: invalid suffix, $compression given.";
+ }
+ return $canonical_suffix;
+}
+
+# Get compression priority
+sub get_priority ($) {
+ my $href = $_[0];
+ my $priority = 0;
+ if ($href =~ m/\.tar\.gz/i) {
+ $priority = 1;
+ }
+ if ($href =~ m/\.tar\.bz2/i) {
+ $priority = 2;
+ }
+ if ($href =~ m/\.tar\.lzma/i) {
+ $priority = 3;
+ }
+ #if ($href =~ m/\.tar\.zstd?/i) {
+ # $priority = 4;
+ #}
+ if ($href =~ m/\.tar\.xz/i) {
+ $priority = 4;
+ }
+ return $priority;
+}
+#######################################################################
+# }}} code 6: utility functions (compression)
+#######################################################################
+
+#######################################################################
+# {{{ code 7: utility functions (regex)
+#######################################################################
+sub quoted_regex_parse($) {
+ my $pattern = shift;
+ my %closers = ('{', '}', '[', ']', '(', ')', '<', '>');
+
+ $pattern =~ /^(s|tr|y)(.)(.*)$/;
+ my ($sep, $rest) = ($2, $3 || '');
+ my $closer = $closers{$sep};
+
+ my $parsed_ok = 1;
+ my $regexp = '';
+ my $replacement = '';
+ my $flags = '';
+ my $open = 1;
+ my $last_was_escape = 0;
+ my $in_replacement = 0;
+
+ for my $char (split //, $rest) {
+ if ($char eq $sep and !$last_was_escape) {
+ $open++;
+ if ($open == 1) {
+ if ($in_replacement) {
+
+ # Separator after end of replacement
+ uscan_warn "Extra \"$sep\" after end of replacement.";
+ $parsed_ok = 0;
+ last;
+ } else {
+ $in_replacement = 1;
+ }
+ } else {
+ if ($open > 1) {
+ if ($in_replacement) {
+ $replacement .= $char;
+ } else {
+ $regexp .= $char;
+ }
+ }
+ }
+ } elsif ($char eq $closer and !$last_was_escape) {
+ $open--;
+ if ($open > 0) {
+ if ($in_replacement) {
+ $replacement .= $char;
+ } else {
+ $regexp .= $char;
+ }
+ } elsif ($open < 0) {
+ uscan_warn "Extra \"$closer\" after end of replacement.";
+ $parsed_ok = 0;
+ last;
+ }
+ } else {
+ if ($in_replacement) {
+ if ($open) {
+ $replacement .= $char;
+ } else {
+ $flags .= $char;
+ }
+ } else {
+ if ($open) {
+ $regexp .= $char;
+ } elsif ($char !~ m/\s/) {
+ uscan_warn
+ "Non-whitespace between <...> and <...> (or similars).";
+ $parsed_ok = 0;
+ last;
+ }
+
+ # skip if blanks between <...> and <...> (or similars)
+ }
+ }
+
+ # Don't treat \\ as an escape
+ $last_was_escape = ($char eq '\\' and !$last_was_escape);
+ }
+
+ unless ($in_replacement and $open == 0) {
+ uscan_warn "Empty replacement string.";
+ $parsed_ok = 0;
+ }
+
+ return ($parsed_ok, $regexp, $replacement, $flags);
+}
+
+sub safe_replace($$) {
+ my ($in, $pat) = @_;
+ eval "uscan_debug \"safe_replace input=\\\"\$\$in\\\"\\n\"";
+ $pat =~ s/^\s*(.*?)\s*$/$1/;
+
+ $pat =~ /^(s|tr|y)(.)/;
+ my ($op, $sep) = ($1, $2 || '');
+ my $esc = "\Q$sep\E";
+ my ($parsed_ok, $regexp, $replacement, $flags);
+
+ if ($sep eq '{' or $sep eq '(' or $sep eq '[' or $sep eq '<') {
+ ($parsed_ok, $regexp, $replacement, $flags) = quoted_regex_parse($pat);
+
+ unless ($parsed_ok) {
+ uscan_warn "stop mangling: rule=\"$pat\"\n"
+ . " mangling rule with <...>, (...), {...} failed.";
+ return 0;
+ }
+ } elsif ($pat
+ !~ /^(?:s|tr|y)$esc((?:\\.|[^\\$esc])*)$esc((?:\\.|[^\\$esc])*)$esc([a-z]*)$/
+ ) {
+ $sep = "/" if $sep eq '';
+ uscan_warn "stop mangling: rule=\"$pat\"\n"
+ . " rule doesn't match \"(s|tr|y)$sep.*$sep.*$sep\[a-z\]*\" (or similar).";
+ return 0;
+ } else {
+ ($regexp, $replacement, $flags) = ($1, $2, $3);
+ }
+
+ uscan_debug
+"safe_replace with regexp=\"$regexp\", replacement=\"$replacement\", and flags=\"$flags\"";
+ my $safeflags = $flags;
+ if ($op eq 'tr' or $op eq 'y') {
+ $safeflags =~ tr/cds//cd;
+ if ($safeflags ne $flags) {
+ uscan_warn "stop mangling: rule=\"$pat\"\n"
+ . " flags must consist of \"cds\" only.";
+ return 0;
+ }
+
+ $regexp =~ s/\\(.)/$1/g;
+ $replacement =~ s/\\(.)/$1/g;
+
+ $regexp =~ s/([^-])/'\\x' . unpack 'H*', $1/ge;
+ $replacement =~ s/([^-])/'\\x' . unpack 'H*', $1/ge;
+
+ eval "\$\$in =~ tr<$regexp><$replacement>$flags;";
+
+ if ($@) {
+ uscan_warn "stop mangling: rule=\"$pat\"\n"
+ . " mangling \"tr\" or \"y\" rule execution failed.";
+ return 0;
+ } else {
+ return 1;
+ }
+ } else {
+ $safeflags =~ tr/gix//cd;
+ if ($safeflags ne $flags) {
+ uscan_warn "stop mangling: rule=\"$pat\"\n"
+ . " flags must consist of \"gix\" only.";
+ return 0;
+ }
+
+ my $global = ($flags =~ s/g//);
+ $flags = "(?$flags)" if length $flags;
+
+ my $slashg;
+ if ($regexp =~ /(?<!\\)(\\\\)*\\G/) {
+ $slashg = 1;
+
+ # if it's not initial, it is too dangerous
+ if ($regexp =~ /^.*[^\\](\\\\)*\\G/) {
+ uscan_warn "stop mangling: rule=\"$pat\"\n"
+ . " dangerous use of \\G with regexp=\"$regexp\".";
+ return 0;
+ }
+ }
+
+ # Behave like Perl and treat e.g. "\." in replacement as "."
+ # We allow the case escape characters to remain and
+ # process them later
+ $replacement =~ s/(^|[^\\])\\([^luLUE])/$1$2/g;
+
+ # Unescape escaped separator characters
+ $replacement =~ s/\\\Q$sep\E/$sep/g;
+
+ # If bracketing quotes were used, also unescape the
+ # closing version
+ ### {{ ### (FOOL EDITOR for non-quoted kets)
+ $replacement =~ s/\\\Q}\E/}/g if $sep eq '{';
+ $replacement =~ s/\\\Q]\E/]/g if $sep eq '[';
+ $replacement =~ s/\\\Q)\E/)/g if $sep eq '(';
+ $replacement =~ s/\\\Q>\E/>/g if $sep eq '<';
+
+ # The replacement below will modify $replacement so keep
+ # a copy. We'll need to restore it to the current value if
+ # the global flag was set on the input pattern.
+ my $orig_replacement = $replacement;
+
+ my ($first, $last, $pos, $zerowidth, $matched, @captures) = (0, -1, 0);
+ while (1) {
+ eval {
+ # handle errors due to unsafe constructs in $regexp
+ no re 'eval';
+
+ # restore position
+ pos($$in) = $pos if $pos;
+
+ if ($zerowidth) {
+
+ # previous match was a zero-width match, simulate it to set
+ # the internal flag that avoids the infinite loop
+ $$in =~ /()/g;
+ }
+
+ # Need to use /g to make it use and save pos()
+ $matched = ($$in =~ /$flags$regexp/g);
+
+ if ($matched) {
+
+ # save position and size of the match
+ my $oldpos = $pos;
+ $pos = pos($$in);
+ ($first, $last) = ($-[0], $+[0]);
+
+ if ($slashg) {
+
+ # \G in the match, weird things can happen
+ $zerowidth = ($pos == $oldpos);
+
+ # For example, matching without a match
+ $matched = 0
+ if ( not defined $first
+ or not defined $last);
+ } else {
+ $zerowidth = ($last - $first == 0);
+ }
+ for my $i (0 .. $#-) {
+ $captures[$i] = substr $$in, $-[$i], $+[$i] - $-[$i];
+ }
+ }
+ };
+ if ($@) {
+ uscan_warn "stop mangling: rule=\"$pat\"\n"
+ . " mangling \"s\" rule execution failed.";
+ return 0;
+ }
+
+ # No match; leave the original string untouched but return
+ # success as there was nothing wrong with the pattern
+ return 1 unless $matched;
+
+ # Replace $X
+ $replacement
+ =~ s/[\$\\](\d)/defined $captures[$1] ? $captures[$1] : ''/ge;
+ $replacement
+ =~ s/\$\{(\d)\}/defined $captures[$1] ? $captures[$1] : ''/ge;
+ $replacement =~ s/\$&/$captures[0]/g;
+
+ # Make \l etc escapes work
+ $replacement =~ s/\\l(.)/lc $1/e;
+ $replacement =~ s/\\L(.*?)(\\E|\z)/lc $1/e;
+ $replacement =~ s/\\u(.)/uc $1/e;
+ $replacement =~ s/\\U(.*?)(\\E|\z)/uc $1/e;
+
+ # Actually do the replacement
+ substr $$in, $first, $last - $first, $replacement;
+
+ # Update position
+ $pos += length($replacement) - ($last - $first);
+
+ if ($global) {
+ $replacement = $orig_replacement;
+ } else {
+ last;
+ }
+ }
+
+ return 1;
+ }
+}
+
+# call this as
+# if mangle($watchfile, \$line, 'uversionmangle:',
+# \@{$options{'uversionmangle'}}, \$version) {
+# return 1;
+# }
+sub mangle($$$$$) {
+ my ($watchfile, $lineptr, $name, $rulesptr, $verptr) = @_;
+ foreach my $pat (@{$rulesptr}) {
+ if (!safe_replace($verptr, $pat)) {
+ uscan_warn "In $watchfile, potentially"
+ . " unsafe or malformed $name"
+ . " pattern:\n '$pat'"
+ . " found. Skipping watchline\n"
+ . " $$lineptr";
+ return 1;
+ }
+ uscan_debug "After $name $$verptr";
+ }
+ return 0;
+}
+
+*uscan_exec_no_fail = \&ds_exec_no_fail;
+
+*uscan_exec = \&ds_exec;
+
+#######################################################################
+# }}} code 7: utility functions (regex)
+#######################################################################
+
+1;
diff --git a/lib/Devscripts/Uscan/WatchFile.pm b/lib/Devscripts/Uscan/WatchFile.pm
new file mode 100644
index 0000000..71be449
--- /dev/null
+++ b/lib/Devscripts/Uscan/WatchFile.pm
@@ -0,0 +1,517 @@
+
+=head1 NAME
+
+Devscripts::Uscan::WatchFile - watchfile object for L<uscan>
+
+=head1 SYNOPSIS
+
+ use Devscripts::Uscan::Config;
+ use Devscripts::Uscan::WatchFile;
+
+ my $config = Devscripts::Uscan::Config->new({
+ # Uscan config parameters. Example:
+ destdir => '..',
+ });
+
+ # You can use Devscripts::Uscan::FindFiles to find watchfiles
+
+ my $wf = Devscripts::Uscan::WatchFile->new({
+ config => $config,
+ package => $package,
+ pkg_dir => $pkg_dir,
+ pkg_version => $version,
+ watchfile => $watchfile,
+ });
+ return $wf->status if ( $wf->status );
+
+ # Do the job
+ return $wf->process_lines;
+
+=head1 DESCRIPTION
+
+Uscan class to parse watchfiles.
+
+=head1 METHODS
+
+=head2 new() I<(Constructor)>
+
+Parse watch file and creates L<Devscripts::Uscan::WatchLine> objects for
+each line.
+
+=head3 Required parameters
+
+=over
+
+=item config: L<Devscripts::Uscan::Config> object
+
+=item package: Debian package name
+
+=item pkg_dir: Working directory
+
+=item pkg_version: Current Debian package version
+
+=back
+
+=head2 Main accessors
+
+=over
+
+=item watchlines: ref to the array that contains watchlines objects
+
+=item watch_version: format version of the watchfile
+
+=back
+
+=head2 process_lines()
+
+Method that launches Devscripts::Uscan::WatchLine::process() on each watchline.
+
+=head1 SEE ALSO
+
+L<uscan>, L<Devscripts::Uscan::WatchLine>, L<Devscripts::Uscan::Config>,
+L<Devscripts::Uscan::FindFiles>
+
+=head1 AUTHOR
+
+B<uscan> was originally written by Christoph Lameter
+E<lt>clameter@debian.orgE<gt> (I believe), modified by Julian Gilbey
+E<lt>jdg@debian.orgE<gt>. HTTP support was added by Piotr Roszatycki
+E<lt>dexter@debian.orgE<gt>. B<uscan> was rewritten in Perl by Julian Gilbey.
+Xavier Guimard E<lt>yadd@debian.orgE<gt> rewrote uscan in object
+oriented Perl.
+
+=head1 COPYRIGHT AND LICENSE
+
+Copyright 2002-2006 by Julian Gilbey <jdg@debian.org>,
+2018 by Xavier Guimard <yadd@debian.org>
+
+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 2 of the License, or
+(at your option) any later version.
+
+=cut
+
+package Devscripts::Uscan::WatchFile;
+
+use strict;
+use Devscripts::Uscan::Downloader;
+use Devscripts::Uscan::Output;
+use Devscripts::Uscan::WatchLine;
+use Dpkg::Version;
+use File::Copy qw/copy move/;
+use List::Util qw/first/;
+use Moo;
+
+use constant {
+ ANY_VERSION => '(?:[-_]?[Vv]?(\d[\-+\.:\~\da-zA-Z]*))',
+ ARCHIVE_EXT =>
+ '(?i)(?:\.(?:tar\.xz|tar\.bz2|tar\.gz|tar\.zstd?|zip|tgz|tbz|txz))',
+ DEB_EXT => '(?:[\+~](debian|dfsg|ds|deb)(\.)?(\d+)?$)',
+};
+use constant SIGNATURE_EXT => ARCHIVE_EXT . '(?:\.(?:asc|pgp|gpg|sig|sign))';
+
+# Required new() parameters
+has config => (is => 'rw', required => 1);
+has package => (is => 'ro', required => 1); # Debian package
+has pkg_dir => (is => 'ro', required => 1);
+has pkg_version => (is => 'ro', required => 1);
+has bare => (
+ is => 'rw',
+ lazy => 1,
+ default => sub { $_[0]->config->bare });
+has download => (
+ is => 'rw',
+ lazy => 1,
+ default => sub { $_[0]->config->download });
+has downloader => (
+ is => 'ro',
+ lazy => 1,
+ default => sub {
+ Devscripts::Uscan::Downloader->new({
+ timeout => $_[0]->config->timeout,
+ agent => $_[0]->config->user_agent,
+ pasv => $_[0]->config->pasv,
+ destdir => $_[0]->config->destdir,
+ headers => $_[0]->config->http_header,
+ });
+ },
+);
+has signature => (
+ is => 'rw',
+ required => 1,
+ lazy => 1,
+ default => sub { $_[0]->config->signature });
+has watchfile => (is => 'ro', required => 1); # usually debian/watch
+
+# Internal attributes
+has group => (is => 'rw', default => sub { [] });
+has origcount => (is => 'rw');
+has origtars => (is => 'rw', default => sub { [] });
+has status => (is => 'rw', default => sub { 0 });
+has watch_version => (is => 'rw');
+has watchlines => (is => 'rw', default => sub { [] });
+
+# Values shared between lines
+has shared => (
+ is => 'rw',
+ lazy => 1,
+ default => \&new_shared,
+);
+
+sub new_shared {
+ return {
+ bare => $_[0]->bare,
+ components => [],
+ common_newversion => undef,
+ common_mangled_newversion => undef,
+ download => $_[0]->download,
+ download_version => undef,
+ origcount => undef,
+ origtars => [],
+ previous_download_available => undef,
+ previous_newversion => undef,
+ previous_newfile_base => undef,
+ previous_sigfile_base => undef,
+ signature => $_[0]->signature,
+ uscanlog => undef,
+ };
+}
+has keyring => (
+ is => 'ro',
+ default => sub { Devscripts::Uscan::Keyring->new });
+
+sub BUILD {
+ my ($self, $args) = @_;
+ my $watch_version = 0;
+ my $nextline;
+ $dehs_tags = {};
+
+ uscan_verbose "Process watch file at: $args->{watchfile}\n"
+ . " package = $args->{package}\n"
+ . " version = $args->{pkg_version}\n"
+ . " pkg_dir = $args->{pkg_dir}";
+
+ $self->origcount(0); # reset to 0 for each watch file
+ unless (open WATCH, $args->{watchfile}) {
+ uscan_warn "could not open $args->{watchfile}: $!";
+ return 1;
+ }
+
+ my $lineNumber = 0;
+ while (<WATCH>) {
+ next if /^\s*\#/;
+ next if /^\s*$/;
+ s/^\s*//;
+
+ CHOMP:
+
+ # Reassemble lines split using \
+ chomp;
+ if (s/(?<!\\)\\$//) {
+ if (eof(WATCH)) {
+ uscan_warn
+ "$args->{watchfile} ended with \\; skipping last line";
+ $self->status(1);
+ last;
+ }
+ if ($watch_version > 3) {
+
+ # drop leading \s only if version 4
+ $nextline = <WATCH>;
+ $nextline =~ s/^\s*//;
+ $_ .= $nextline;
+ } else {
+ $_ .= <WATCH>;
+ }
+ goto CHOMP;
+ }
+
+ # "version" must be the first field
+ if (!$watch_version) {
+
+ # Looking for "version" field.
+ if (/^version\s*=\s*(\d+)(\s|$)/) { # Found
+ $watch_version = $1;
+
+ # Note that version=1 watchfiles have no "version" field so
+ # authorizated values are >= 2 and <= CURRENT_WATCHFILE_VERSION
+ if ( $watch_version < 2
+ or $watch_version
+ > $Devscripts::Uscan::Config::CURRENT_WATCHFILE_VERSION) {
+ # "version" field found but has no authorizated value
+ uscan_warn
+"$args->{watchfile} version number is unrecognised; skipping watch file";
+ last;
+ }
+
+ # Next line
+ next;
+ }
+
+ # version=1 is deprecated
+ else {
+ $watch_version = 1;
+ }
+ }
+ if ($watch_version < 3) {
+ uscan_warn
+"$args->{watchfile} is an obsolete version $watch_version watch file;\n"
+ . " please upgrade to a higher version\n"
+ . " (see uscan(1) for details).";
+ }
+
+ # "version" is fixed, parsing lines now
+
+ # Are there any warnings from this part to give if we're using dehs?
+ dehs_output if ($dehs);
+
+ # Handle shell \\ -> \
+ s/\\\\/\\/g if $watch_version == 1;
+
+ # Handle @PACKAGE@ @ANY_VERSION@ @ARCHIVE_EXT@ substitutions
+ s/\@PACKAGE\@/$args->{package}/g;
+ s/\@ANY_VERSION\@/ANY_VERSION/ge;
+ s/\@ARCHIVE_EXT\@/ARCHIVE_EXT/ge;
+ s/\@SIGNATURE_EXT\@/SIGNATURE_EXT/ge;
+ s/\@DEB_EXT\@/DEB_EXT/ge;
+
+ my $line = Devscripts::Uscan::WatchLine->new({
+ # Shared between lines
+ config => $self->config,
+ downloader => $self->downloader,
+ shared => $self->shared,
+ keyring => $self->keyring,
+
+ # Other parameters
+ line => $_,
+ pkg => $self->package,
+ pkg_dir => $self->pkg_dir,
+ pkg_version => $self->pkg_version,
+ watch_version => $watch_version,
+ watchfile => $self->watchfile,
+ });
+ push @{ $self->group }, $lineNumber
+ if ($line->type and $line->type =~ /^(?:group|checksum)$/);
+ push @{ $self->watchlines }, $line;
+ $lineNumber++;
+ }
+
+ close WATCH
+ or $self->status(1),
+ uscan_warn "problems reading $$args->{watchfile}: $!";
+ $self->watch_version($watch_version);
+}
+
+sub process_lines {
+ my ($self) = shift;
+ return $self->process_group if (@{ $self->group });
+ foreach (@{ $self->watchlines }) {
+
+ # search newfile and newversion
+ my $res = $_->process;
+ $self->status($res) if ($res);
+ }
+ return $self->{status};
+}
+
+sub process_group {
+ my ($self) = @_;
+ my $saveDconfig = $self->config->download_version;
+ # Build version
+ my @cur_versions = split /\+~/, $self->pkg_version;
+ my $checksum = 0;
+ my $newChecksum = 0;
+ if ( $cur_versions[$#cur_versions]
+ and $cur_versions[$#cur_versions] =~ s/^cs//) {
+ $checksum = pop @cur_versions;
+ }
+ my (@new_versions, @last_debian_mangled_uversions, @last_versions);
+ my $download = 0;
+ my $last_shared = $self->shared;
+ my $last_comp_version;
+ my @dversion;
+ my @ck_versions;
+ # Isolate component and following lines
+ if (my $v = $self->config->download_version) {
+ @dversion = map { s/\+.*$//; /^cs/ ? () : $_ } split /\+~/, $v;
+ }
+ foreach my $line (@{ $self->watchlines }) {
+ if ( $line->type and $line->type eq 'group'
+ or $line->type eq 'checksum') {
+ $last_shared = $self->new_shared;
+ $last_comp_version = shift @cur_versions if $line->type eq 'group';
+ }
+ if ($line->type and $line->type eq 'group') {
+ $line->{groupDversion} = shift @dversion;
+ }
+ $line->shared($last_shared);
+ $line->pkg_version($last_comp_version || 0);
+ }
+ # Check if download is needed
+ foreach my $line (@{ $self->watchlines }) {
+ next unless ($line->type eq 'group' or $line->type eq 'checksum');
+ # Stop on error
+ $self->config->download_version($line->{groupDversion})
+ if $line->{groupDversion};
+ $self->config->download_version(undef) if $line->type eq 'checksum';
+ if ( $line->parse
+ or $line->search
+ or $line->get_upstream_url
+ or $line->get_newfile_base
+ or ($line->type eq 'group' and $line->cmp_versions)
+ or ($line->ctype and $line->cmp_versions)) {
+ $self->{status} += $line->status;
+ return $self->{status};
+ }
+ $download = $line->shared->{download}
+ if $line->shared->{download} > $download
+ and ($line->type eq 'group' or $line->ctype);
+ }
+ foreach my $line (@{ $self->watchlines }) {
+ next unless $line->type eq 'checksum';
+ $newChecksum
+ = $self->sum($newChecksum, $line->search_result->{newversion});
+ push @ck_versions, $line->search_result->{newversion};
+ }
+ foreach my $line (@{ $self->watchlines }) {
+ next unless ($line->type eq 'checksum');
+ $line->parse_result->{mangled_lastversion} = $checksum;
+ my $tmp = $line->search_result->{newversion};
+ $line->search_result->{newversion} = $newChecksum;
+ unless ($line->ctype) {
+ if ($line->cmp_versions) {
+ $self->{status} += $line->status;
+ return $self->{status};
+ }
+ $download = $line->shared->{download}
+ if $line->shared->{download} > $download;
+ }
+ $line->search_result->{newversion} = $tmp;
+ if ($line->component) {
+ pop @{ $dehs_tags->{'component-upstream-version'} };
+ push @{ $dehs_tags->{'component-upstream-version'} }, $tmp;
+ }
+ }
+ foreach my $line (@{ $self->watchlines }) {
+ # Set same $download for all
+ $line->shared->{download} = $download;
+ # Non "group" lines where not initialized
+ unless ($line->type eq 'group' or $line->type eq 'checksum') {
+ if ( $line->parse
+ or $line->search
+ or $line->get_upstream_url
+ or $line->get_newfile_base
+ or $line->cmp_versions) {
+ $self->{status} += $line->status;
+ return $self->{status};
+ }
+ }
+ if ($line->download_file_and_sig) {
+ $self->{status} += $line->status;
+ return $self->{status};
+ }
+ if ($line->mkorigtargz) {
+ $self->{status} += $line->status;
+ return $self->{status};
+ }
+ if ($line->type eq 'group') {
+ push @new_versions, $line->shared->{common_mangled_newversion}
+ || $line->shared->{common_newversion}
+ || ();
+ push @last_versions, $line->parse_result->{lastversion};
+ push @last_debian_mangled_uversions,
+ $line->parse_result->{mangled_lastversion};
+ }
+ }
+ my $new_version = join '+~', @new_versions;
+ if ($newChecksum) {
+ $new_version .= "+~cs$newChecksum";
+ }
+ if ($checksum) {
+ push @last_versions, "cs$newChecksum";
+ push @last_debian_mangled_uversions, "cs$checksum";
+ }
+ $dehs_tags->{'upstream-version'} = $new_version;
+ $dehs_tags->{'debian-uversion'} = join('+~', @last_versions)
+ if (grep { $_ } @last_versions);
+ $dehs_tags->{'debian-mangled-uversion'} = join '+~',
+ @last_debian_mangled_uversions
+ if (grep { $_ } @last_debian_mangled_uversions);
+ my $mangled_ver
+ = Dpkg::Version->new(
+ "1:" . $dehs_tags->{'debian-mangled-uversion'} . "-0",
+ check => 0);
+ my $upstream_ver = Dpkg::Version->new("1:$new_version-0", check => 0);
+ if ($mangled_ver == $upstream_ver) {
+ $dehs_tags->{'status'} = "up to date";
+ } elsif ($mangled_ver > $upstream_ver) {
+ $dehs_tags->{'status'} = "only older package available";
+ } else {
+ $dehs_tags->{'status'} = "newer package available";
+ }
+ foreach my $line (@{ $self->watchlines }) {
+ my $path = $line->destfile or next;
+ my $ver = $line->shared->{common_mangled_newversion};
+ $path =~ s/\Q$ver\E/$new_version/;
+ uscan_warn "rename $line->{destfile} to $path\n";
+ rename $line->{destfile}, $path;
+ if ($dehs_tags->{"target-path"} eq $line->{destfile}) {
+ $dehs_tags->{"target-path"} = $path;
+ $dehs_tags->{target} =~ s/\Q$ver\E/$new_version/;
+ } else {
+ for (
+ my $i = 0 ;
+ $i < @{ $dehs_tags->{"component-target-path"} } ;
+ $i++
+ ) {
+ if ($dehs_tags->{"component-target-path"}->[$i] eq
+ $line->{destfile}) {
+ $dehs_tags->{"component-target-path"}->[$i] = $path;
+ $dehs_tags->{"component-target"}->[$i]
+ =~ s/\Q$ver\E/$new_version/
+ or die $ver;
+ }
+ }
+ }
+ if ($line->signature_available) {
+ rename "$line->{destfile}.asc", "$path.asc";
+ rename "$line->{destfile}.sig", "$path.sig";
+ }
+ }
+ if (@ck_versions) {
+ my $v = join '+~', @ck_versions;
+ if ($dehs) {
+ $dehs_tags->{'decoded-checksum'} = $v;
+ } else {
+ uscan_verbose 'Checksum ref: ' . join('+~', @ck_versions) . "\n";
+ }
+ }
+ return 0;
+}
+
+sub sum {
+ my ($self, @versions) = @_;
+ my (@res, @str);
+ foreach my $v (@versions) {
+ my @tmp = grep { $_ ne '.' } version_split_digits($v);
+ for (my $i = 0 ; $i < @tmp ; $i++) {
+ $str[$i] //= '';
+ $res[$i] //= 0;
+ if ($tmp[$i] =~ /^\d+$/) {
+ $res[$i] += $tmp[$i];
+ } else {
+ uscan_die
+"Checksum supports only digits in versions, $tmp[$i] is not accepted";
+ }
+ }
+ }
+ for (my $i = 0 ; $i < @res ; $i++) {
+ my $tmp = shift @str;
+ $res[$i] .= $tmp if $tmp ne '';
+ }
+ push @res, @str;
+ return join '.', @res;
+}
+
+1;
diff --git a/lib/Devscripts/Uscan/WatchLine.pm b/lib/Devscripts/Uscan/WatchLine.pm
new file mode 100644
index 0000000..2dbccf2
--- /dev/null
+++ b/lib/Devscripts/Uscan/WatchLine.pm
@@ -0,0 +1,1850 @@
+
+=pod
+
+=head1 NAME
+
+Devscripts::Uscan::WatchLine - watch line object for L<uscan>
+
+=head1 DESCRIPTION
+
+Uscan class to parse watchfiles.
+
+=head1 MAIN METHODS
+
+=cut
+
+package Devscripts::Uscan::WatchLine;
+
+use strict;
+use Cwd qw/abs_path/;
+use Devscripts::Uscan::Keyring;
+use Devscripts::Uscan::Output;
+use Devscripts::Uscan::Utils;
+use Dpkg::IPC;
+use Dpkg::Version;
+use File::Basename;
+use File::Copy;
+use File::Spec::Functions qw/catfile/;
+use HTTP::Headers;
+use Moo;
+use Text::ParseWords;
+
+#################
+### ACCESSORS ###
+#################
+
+=head2 new() I<(Constructor)>
+
+=head3 Required parameters
+
+=over
+
+=item B<shared>: ref to hash containing line options shared between lines. See
+L<Devscripts::Uscan::WatchFile> code to see required keys.
+
+=item B<keyring>: L<Devscripts::Uscan::Keyring> object
+
+=item B<config>: L<Devscripts::Uscan::Config> object
+
+=item B<downloader>: L<Devscripts::Uscan::Downloader> object
+
+=item B<line>: search line (assembled in one line)
+
+=item B<pkg>: Debian package name
+
+=item B<pkg_dir>: Debian package source directory
+
+=item B<pkg_version>: Debian package version
+
+=item B<watchfile>: Current watchfile
+
+=item B<watch_version>: Version of current watchfile
+
+=back
+
+=cut
+
+foreach (
+
+ # Shared attributes stored in WatchFile object (ref to WatchFile value)
+ 'shared', 'keyring', 'config',
+
+ # Other
+ 'downloader', # Devscripts::Uscan::Downloader object
+ 'line', # watch line string (concatenated line over the tailing \ )
+ 'pkg', # source package name found in debian/changelog
+ 'pkg_dir', # usually .
+ 'pkg_version', # last source package version
+ # found in debian/changelog
+ 'watchfile', # usually debian/watch
+ 'watch_version', # usually 4 (or 3)
+) {
+ has $_ => (is => 'rw', required => 1);
+}
+
+has repack => (
+ is => 'rw',
+ lazy => 1,
+ default => sub { $_[0]->config->{repack} },
+);
+
+has safe => (
+ is => 'rw',
+ lazy => 1,
+ default => sub { $_[0]->config->{safe} },
+);
+
+has symlink => (
+ is => 'rw',
+ lazy => 1,
+ default => sub { $_[0]->config->{symlink} },
+);
+
+has versionmode => (
+ is => 'rw',
+ lazy => 1,
+ default => sub { 'newer' },
+);
+
+has destfile => (is => 'rw');
+has sigfile => (is => 'rw');
+
+# 2 - Line options read/write attributes
+
+foreach (qw(
+ component ctype hrefdecode repacksuffix unzipopt searchmode
+ dirversionmangle downloadurlmangle dversionmangle filenamemangle pagemangle
+ oversionmangle oversionmanglepagemangle pgpsigurlmangle uversionmangle
+ versionmangle
+ )
+) {
+ has $_ => (
+ is => 'rw',
+ (/mangle/ ? (default => sub { [] }) : ()));
+}
+
+has compression => (
+ is => 'rw',
+ lazy => 1,
+ default => sub {
+ $_[0]->config->compression
+ ? get_compression($_[0]->config->compression)
+ : undef;
+ },
+);
+has versionless => (is => 'rw');
+
+# 4 - Internal attributes
+has style => (is => 'rw', default => sub { 'new' });
+has status => (is => 'rw', default => sub { 0 });
+foreach (
+ qw(badversion
+ signature_available must_download)
+) {
+ has $_ => (is => 'rw', default => sub { 0 });
+}
+foreach (qw(mangled_version)) {
+ has $_ => (is => 'rw');
+}
+foreach (qw(sites basedirs patterns)) {
+ has $_ => (is => 'rw', default => sub { [] });
+}
+
+# 5 - Results
+foreach (qw(parse_result search_result)) {
+ has $_ => (is => 'rw', default => sub { {} });
+}
+foreach (qw(force_repack type upstream_url newfile_base)) {
+ has $_ => (is => 'rw');
+}
+
+# 3.1 - Attributes initialized with default value, modified by line content
+has date => (
+ is => 'rw',
+ default => sub { '%Y%m%d' },
+);
+has decompress => (
+ is => 'rw',
+ default => sub { 0 },
+);
+has gitexport => (
+ is => 'rw',
+ default => sub { 'default' },
+);
+has gitmode => (
+ is => 'rw',
+ default => sub { 'shallow' },
+);
+has mode => (
+ is => 'rw',
+ default => sub { 'LWP' },
+);
+has pgpmode => (
+ is => 'rw',
+ default => sub { 'default' },
+);
+has pretty => (
+ is => 'rw',
+ default => sub { '0.0~git%cd.%h' },
+);
+
+# 3.2 - Self build attributes
+
+has gitrepo_dir => ( # Working repository used only within uscan.
+ is => 'ro',
+ lazy => 1,
+ builder => sub {
+ $_[0]->{component}
+ ? $_[0]->{pkg} . "-temporary.$$." . $_[0]->{component} . '.git'
+ : $_[0]->{pkg} . "-temporary.$$.git";
+ });
+has headers => (
+ is => 'ro',
+ default => sub {
+ my $h = HTTP::Headers->new;
+ $h->header(
+ 'X-uscan-features' => 'enhanced-matching',
+ 'Accept' => '*/*'
+ );
+ return $h;
+ },
+);
+
+my $minversion = '';
+
+###############
+# Main method #
+###############
+
+=head2 process()
+
+Launches all needed methods in this order: parse(), search(),
+get_upstream_url(), get_newfile_base(), cmp_versions(),
+download_file_and_sig(), mkorigtargz(), clean()
+
+If one method returns a non 0 value, it stops and return this error code.
+
+=cut
+
+sub process {
+ my ($self) = @_;
+
+ # - parse line
+ $self->parse
+
+ # - search newfile and newversion
+ or $self->search
+
+ # - determine upstream_url
+ or $self->get_upstream_url
+
+ # - determine newfile_base
+ or $self->get_newfile_base
+
+ # - compare versions
+ or $self->cmp_versions
+
+ # - download
+ or $self->download_file_and_sig
+
+ # - make orig.tar.gz
+ or $self->mkorigtargz
+
+ # - clean (used by git)
+ or $self->clean;
+ return $self->status;
+}
+
+#########
+# STEPS #
+#########
+
+=head2 Steps
+
+=cut
+
+# I - parse
+
+=head3 parse()
+
+Parse the line and return 0 if nothing bad happen. It populates
+C<$self-E<gt>parse_result> accessor with a hash that contains the
+following keys:
+
+=over
+
+=item base
+=item filepattern
+=item lastversion
+=item action
+=item site
+=item basedir
+=item mangled_lastversion
+=item pattern
+
+=back
+
+=cut
+
+# watch_version=1: Lines have up to 5 parameters which are:
+#
+# $1 = Remote site
+# $2 = Directory on site
+# $3 = Pattern to match, with (...) around version number part
+# $4 = Last version we have (or 'debian' for the current Debian version)
+# $5 = Actions to take on successful retrieval
+#
+# watch_version=2:
+#
+# For ftp sites:
+# ftp://site.name/dir/path/pattern-(.+)\.tar\.gz [version [action]]
+#
+# For http sites:
+# http://site.name/dir/path/pattern-(.+)\.tar\.gz [version [action]]
+#
+# watch_version=3 and 4: See details in POD.
+#
+# For ftp sites:
+# ftp://site.name/dir/path pattern-(.+)\.tar\.gz [version [action]]
+#
+# For http sites:
+# http://site.name/dir/path pattern-(.+)\.tar\.gz [version [action]]
+#
+# For git sites:
+# http://site.name/dir/path/project.git refs/tags/v([\d\.]+) [version [action]]
+# or
+# http://site.name/dir/path/project.git HEAD [version [action]]
+#
+# For svn sites:
+# http://site.name/dir/path/project/tags v([\d\.]+)\/ [version [action]]
+# or
+# http://site.name/dir/path/project/trunk HEAD [version [action]]
+#
+# watch_version=3 and 4: See POD for details.
+#
+# Lines can be prefixed with opts=<opts> but can be folded for readability.
+#
+# Then the patterns matched will be checked to find the one with the
+# greatest version number (as determined by the (...) group), using the
+# Debian version number comparison algorithm described below.
+
+sub BUILD {
+ my ($self, $args) = @_;
+ if ($self->watch_version > 3) {
+ my $line = $self->line;
+ if ($line =~ s/^opt(?:ion)?s\s*=\s*//) {
+ unless ($line =~ s/^".*?"(?:\s+|$)//) {
+ $line =~ s/^[^"\s]\S*(?:\s+|$)//;
+ }
+ }
+ my ($base, $filepattern, $lastversion, $action) = split /\s+/, $line,
+ 4;
+ $self->type($lastversion);
+ }
+ return $self;
+}
+
+sub parse {
+ my ($self) = @_;
+ uscan_debug "parse line $self->{line}";
+
+ # Need to clear remembered redirection URLs so we don't try to build URLs
+ # from previous watch files or watch lines
+ $self->downloader->user_agent->clear_redirections;
+
+ my $watchfile = $self->watchfile;
+ my ($action, $base, $basedir, $filepattern, $lastversion, $pattern, $site);
+ $dehs_tags->{package} = $self->pkg;
+
+ # Start parsing the watch line
+ if ($self->watch_version == 1) {
+ my ($dir);
+ ($site, $dir, $filepattern, $lastversion, $action) = split ' ',
+ $self->line, 5;
+ if ( !$lastversion
+ or $site =~ /\(.*\)/
+ or $dir =~ /\(.*\)/) {
+ uscan_warn <<EOF;
+there appears to be a version 2 format line in
+the version 1 watch file $watchfile;
+Have you forgotten a 'version=2' line at the start, perhaps?
+Skipping the line: $self->{line}
+EOF
+ return $self->status(1);
+ }
+ if ($site !~ m%\w+://%) {
+ $site = "ftp://$site";
+ if ($filepattern !~ /\(.*\)/) {
+
+ # watch_version=1 and old style watch file;
+ # pattern uses ? and * shell wildcards; everything from the
+ # first to last of these metachars is the pattern to match on
+ $filepattern =~ s/(\?|\*)/($1/;
+ $filepattern =~ s/(\?|\*)([^\?\*]*)$/$1)$2/;
+ $filepattern =~ s/\./\\./g;
+ $filepattern =~ s/\?/./g;
+ $filepattern =~ s/\*/.*/g;
+ $self->style('old');
+ uscan_warn
+ "Using very old style of filename pattern in $watchfile\n"
+ . " (this might lead to incorrect results): $3";
+ }
+ }
+
+ # Merge site and dir
+ $base = "$site/$dir/";
+ $base =~ s%(?<!:)//%/%g;
+ $base =~ m%^(\w+://[^/]+)%;
+ $site = $1;
+ $pattern = $filepattern;
+
+ # Check $filepattern is OK
+ if ($filepattern !~ /\(.*\)/) {
+ uscan_warn "Filename pattern missing version delimiters ()\n"
+ . " in $watchfile, skipping:\n $self->{line}";
+ return $self->status(1);
+ }
+ } else {
+ # version 2/3/4 watch file
+ if ($self->{line} =~ s/^opt(?:ion)?s\s*=\s*//) {
+ my $opts;
+ if ($self->{line} =~ s/^"(.*?)"(?:\s+|$)//) {
+ $opts = $1;
+ } elsif ($self->{line} =~ s/^([^"\s]\S*)(?:\s+|$)//) {
+ $opts = $1;
+ } else {
+ uscan_warn
+"malformed opts=... in watch file, skipping line:\n$self->{line}";
+ return $self->status(1);
+ }
+
+ # $opts string extracted from the argument of opts=
+ uscan_verbose "opts: $opts";
+
+ # $self->line watch line string without opts=... part
+ uscan_verbose "line: $self->{line}";
+
+ # user-agent strings has ,;: in it so special handling
+ if ( $opts =~ /^\s*user-agent\s*=\s*(.+?)\s*$/
+ or $opts =~ /^\s*useragent\s*=\s*(.+?)\s*$/) {
+ my $user_agent_string = $1;
+ $user_agent_string = $self->config->user_agent
+ if $self->config->user_agent ne
+ &Devscripts::Uscan::Config::default_user_agent;
+ $self->downloader->user_agent->agent($user_agent_string);
+ uscan_verbose "User-agent: $user_agent_string";
+ $opts = '';
+ }
+ my @opts = split /,/, $opts;
+ foreach my $opt (@opts) {
+ next unless ($opt =~ /\S/);
+ uscan_verbose "Parsing $opt";
+ if ($opt =~ /^\s*pasv\s*$/ or $opt =~ /^\s*passive\s*$/) {
+ $self->downloader->pasv(1);
+ } elsif ($opt =~ /^\s*active\s*$/
+ or $opt =~ /^\s*nopasv\s*$/
+ or $opt =~ /^s*nopassive\s*$/) {
+ $self->downloader->pasv(0);
+ }
+
+ # Line option "compression" is ignored if "--compression"
+ # was set in command-line
+ elsif ($opt =~ /^\s*compression\s*=\s*(.+?)\s*$/
+ and not $self->compression) {
+ $self->compression(get_compression($1));
+ } elsif ($opt =~ /^\s*bare\s*$/) {
+
+ # persistent $bare
+ ${ $self->shared->{bare} } = 1;
+ }
+
+ # Boolean line parameter
+ #
+ # $ regexp-assemble <<EOF
+ # decompress
+ # repack
+ # EOF
+ elsif ($opt =~ /^\s*(decompress|repack)\s*$/) {
+ $self->$1(1);
+ }
+
+ # Line parameter with a value
+ #
+ # $ regexp-assemble <<EOF
+ # component
+ # ctype
+ # date
+ # gitexport
+ # gitmode
+ # hrefdecode
+ # mode
+ # pgpmode
+ # pretty
+ # repacksuffix
+ # searchmode
+ # unzipopt
+ # EOF
+ elsif ($opt
+ =~ /^\s*((?:(?:(?:(?:search)?m|hrefdec)od|dat)e|c(?:omponent|type)|git(?:export|mode)|p(?:gpmode|retty)|repacksuffix|unzipopt))\s*=\s*(.+?)\s*$/
+ ) {
+ $self->$1($2);
+ } elsif ($opt =~ /^\s*versionmangle\s*=\s*(.+?)\s*$/) {
+ $self->uversionmangle([split /;/, $1]);
+ $self->dversionmangle([split /;/, $1]);
+ } elsif ($opt =~ /^\s*pgpsigurlmangle\s*=\s*(.+?)\s*$/) {
+ $self->pgpsigurlmangle([split /;/, $1]);
+ $self->pgpmode('mangle');
+ } elsif ($opt =~ /^\s*dversionmangle\s*=\s*(.+?)\s*$/) {
+
+ $self->dversionmangle([
+ map {
+
+ # If dversionmangle is "auto", replace it by
+ # DEB_EXT removal
+ $_ eq 'auto'
+ ? ('s/'
+ . &Devscripts::Uscan::WatchFile::DEB_EXT
+ . '//')
+ : ($_)
+ } split /;/,
+ $1
+ ]);
+ }
+
+ # Handle other *mangle:
+ #
+ # $ regexp-assemble <<EOF
+ # pagemangle
+ # dirversionmangle
+ # uversionmangle
+ # downloadurlmangle
+ # filenamemangle
+ # oversionmangle
+ # EOF
+ elsif ($opt
+ =~ /^\s*((?:d(?:ownloadurl|irversion)|(?:filenam|pag)e|[ou]version)mangle)\s*=\s*(.+?)\s*$/
+ ) {
+ $self->$1([split /;/, $2]);
+ } else {
+ uscan_warn "unrecognized option $opt";
+ }
+ }
+
+ # $self->line watch line string when no opts=...
+ uscan_verbose "line: $self->{line}";
+ }
+
+ if ($self->line eq '') {
+ uscan_verbose "watch line only with opts=\"...\" and no URL";
+ return $self->status(1);
+ }
+
+ # 4 parameter watch line
+ ($base, $filepattern, $lastversion, $action) = split /\s+/,
+ $self->line, 4;
+
+ # 3 parameter watch line (override)
+ if ($base =~ s%/([^/]*\([^/]*\)[^/]*)$%/%) {
+
+ # Last component of $base has a pair of parentheses, so no
+ # separate filepattern field; we remove the filepattern from the
+ # end of $base and rescan the rest of the line
+ $filepattern = $1;
+ (undef, $lastversion, $action) = split /\s+/, $self->line, 3;
+ }
+
+ # Always define "" if not defined
+ $lastversion //= '';
+ $action //= '';
+ if ($self->mode eq 'LWP') {
+ if ($base =~ m%^https?://%) {
+ $self->mode('http');
+ } elsif ($base =~ m%^ftp://%) {
+ $self->mode('ftp');
+ } else {
+ uscan_warn "unknown protocol for LWP: $base";
+ return $self->status(1);
+ }
+ }
+
+ # compression is persistent
+ $self->compression('default') unless ($self->compression);
+
+ # Set $lastversion to the numeric last version
+ # Update $self->versionmode (its default "newer")
+ if (!length($lastversion)
+ or $lastversion =~ /^(group|checksum|debian)$/) {
+ if (!defined $self->pkg_version) {
+ uscan_warn "Unable to determine the current version\n"
+ . " in $watchfile, skipping:\n $self->{line}";
+ return $self->status(1);
+ }
+ $lastversion = $self->pkg_version;
+ } elsif ($lastversion eq 'ignore') {
+ $self->versionmode('ignore');
+ $lastversion = $minversion;
+ } elsif ($lastversion eq 'same') {
+ $self->versionmode('same');
+ $lastversion = $minversion;
+ } elsif ($lastversion =~ m/^prev/) {
+ $self->versionmode('previous');
+
+ # set $lastversion = $previous_newversion later
+ }
+
+ # Check $filepattern has ( ...)
+ if ($filepattern !~ /\([^?].*\)/) {
+ if (($self->mode eq 'git' or $self->mode eq 'svn')
+ and $filepattern eq 'HEAD') {
+ $self->versionless(1);
+ } elsif ($self->mode eq 'git'
+ and $filepattern =~ m&^heads/&) {
+ $self->versionless(1);
+ } elsif ($self->mode eq 'http'
+ and @{ $self->filenamemangle }) {
+ $self->versionless(1);
+ } else {
+ uscan_warn
+ "Tag pattern missing version delimiters () in $watchfile"
+ . ", skipping:\n $self->{line}";
+ return $self->status(1);
+ }
+ }
+
+ # Check validity of options
+ if ($self->mode eq 'ftp'
+ and @{ $self->downloadurlmangle }) {
+ uscan_warn "downloadurlmangle option invalid for ftp sites,\n"
+ . " ignoring downloadurlmangle in $watchfile:\n"
+ . " $self->{line}";
+ return $self->status(1);
+ }
+ if ($self->mode ne 'git' and $self->gitexport ne 'default') {
+ uscan_warn "gitexport option is valid only in git mode,\n"
+ . " ignoring gitexport in $watchfile:\n"
+ . " $self->{line}";
+ return $self->status(1);
+ }
+
+ # Limit use of opts="repacksuffix" to the single upstream package
+ if ($self->repacksuffix and @{ $self->shared->{components} }) {
+ uscan_warn
+"repacksuffix is not compatible with the multiple upstream tarballs;\n"
+ . " use oversionmangle";
+ return $self->status(1);
+ }
+
+ # Allow 2 char shorthands for opts="pgpmode=..." and check
+ if ($self->pgpmode =~ m/^au/) {
+ $self->pgpmode('auto');
+ if (@{ $self->pgpsigurlmangle }) {
+ uscan_warn "Ignore pgpsigurlmangle because pgpmode=auto";
+ $self->pgpsigurlmangle([]);
+ }
+ } elsif ($self->pgpmode =~ m/^ma/) {
+ $self->pgpmode('mangle');
+ if (not @{ $self->pgpsigurlmangle }) {
+ uscan_warn "Missing pgpsigurlmangle. Setting pgpmode=default";
+ $self->pgpmode('default');
+ }
+ } elsif ($self->pgpmode =~ m/^no/) {
+ $self->pgpmode('none');
+ } elsif ($self->pgpmode =~ m/^ne/) {
+ $self->pgpmode('next');
+ } elsif ($self->pgpmode =~ m/^pr/) {
+ $self->pgpmode('previous');
+ $self->versionmode('previous'); # no other value allowed
+ # set $lastversion = $previous_newversion later
+ } elsif ($self->pgpmode =~ m/^se/) {
+ $self->pgpmode('self');
+ } elsif ($self->pgpmode =~ m/^git/) {
+ $self->pgpmode('gittag');
+ } else {
+ $self->pgpmode('default');
+ }
+
+ # For mode=svn, make pgpmode=none the default
+ if ($self->mode eq 'svn') {
+ if ($self->pgpmode eq 'default') {
+ $self->pgpmode('none');
+ } elsif ($self->pgpmode ne 'none') {
+ uscan_die "Only pgpmode=none can be used with mode=svn.\n";
+ }
+ }
+
+ # If PGP used, check required programs and generate files
+ if (@{ $self->pgpsigurlmangle }) {
+ my $pgpsigurlmanglestring = join(";", @{ $self->pgpsigurlmangle });
+ uscan_debug "\$self->{'pgpmode'}=$self->{'pgpmode'}, "
+ . "\$self->{'pgpsigurlmangle'}=$pgpsigurlmanglestring";
+ } else {
+ uscan_debug "\$self->{'pgpmode'}=$self->{'pgpmode'}, "
+ . "\$self->{'pgpsigurlmangle'}=undef";
+ }
+
+ # Check component for duplication and set $orig to the proper
+ # extension string
+ if ($self->pgpmode ne 'previous') {
+ if ($self->component) {
+ if (grep { $_ eq $self->component }
+ @{ $self->shared->{components} }) {
+ uscan_warn "duplicate component name: $self->{component}";
+ return $self->status(1);
+ }
+ push @{ $self->shared->{components} }, $self->component;
+ } else {
+ $self->shared->{origcount}++;
+ if ($self->shared->{origcount} > 1) {
+ uscan_warn "more than one main upstream tarballs listed.";
+
+ # reset variables
+ @{ $self->shared->{components} } = ();
+ $self->{shared}->{common_newversion} = undef;
+ $self->{shared}->{common_mangled_newversion} = undef;
+ $self->{shared}->{previous_newversion} = undef;
+ $self->{shared}->{previous_newfile_base} = undef;
+ $self->{shared}->{previous_sigfile_base} = undef;
+ $self->{shared}->{previous_download_available} = undef;
+ $self->{shared}->{uscanlog} = undef;
+ }
+ }
+ }
+
+ # Allow 2 char shorthands for opts="gitmode=..." and check
+ if ($self->gitmode =~ m/^sh/) {
+ $self->gitmode('shallow');
+ } elsif ($self->gitmode =~ m/^fu/) {
+ $self->gitmode('full');
+ } else {
+ uscan_warn
+ "Override strange manual gitmode '$self->gitmode --> 'shallow'";
+ $self->gitmode('shallow');
+ }
+
+ # Handle sf.net addresses specially
+ if (!$self->shared->{bare} and $base =~ m%^https?://sf\.net/%) {
+ uscan_verbose "sf.net redirection to qa.debian.org/watch/sf.php";
+ $base =~ s%^https?://sf\.net/%https://qa.debian.org/watch/sf.php/%;
+ $filepattern .= '(?:\?.*)?';
+ }
+
+ # Handle pypi.python.org addresses specially
+ if ( !$self->shared->{bare}
+ and $base =~ m%^https?://pypi\.python\.org/packages/source/%) {
+ uscan_verbose "pypi.python.org redirection to pypi.debian.net";
+ $base
+ =~ s%^https?://pypi\.python\.org/packages/source/./%https://pypi.debian.net/%;
+ }
+
+ # Handle pkg-ruby-extras gemwatch addresses specially
+ if ($base
+ =~ m%^https?://pkg-ruby-extras\.alioth\.debian\.org/cgi-bin/gemwatch%
+ ) {
+ uscan_warn
+"redirecting DEPRECATED pkg-ruby-extras.alioth.debian.org/cgi-bin/gemwatch"
+ . " to gemwatch.debian.net";
+ $base
+ =~ s%^https?://pkg-ruby-extras\.alioth\.debian\.org/cgi-bin/gemwatch%https://gemwatch.debian.net%;
+ }
+
+ }
+
+ if ($self->ctype) {
+ my $version;
+ my $mod = "Devscripts::Uscan::Ctype::$self->{ctype}";
+ eval "require $mod";
+ if ($@) {
+ uscan_warn "unknown ctype $self->{ctype}";
+ uscan_debug $@;
+ return $self->status(1);
+ }
+ my $dir = $self->component || '.';
+ my $ctypeTransform = $mod->new({ dir => $dir });
+ if ($version = $ctypeTransform->version) {
+ $lastversion = $version;
+ uscan_verbose "Found version $version for component $dir";
+ $self->versionmode('newer');
+ }
+ }
+
+ # End parsing the watch line for all version=1/2/3/4
+ # all options('...') variables have been set
+
+ # Override the last version with --download-debversion
+ if ($self->config->download_debversion) {
+ $lastversion = $self->config->download_debversion;
+ $lastversion =~ s/-[^-]+$//; # revision
+ $lastversion =~ s/^\d+://; # epoch
+ uscan_verbose
+"specified --download-debversion to set the last version: $lastversion";
+ } elsif ($self->versionmode eq 'previous') {
+ $lastversion = $self->shared->{previous_newversion};
+ # $lastversion is set only if something was downloaded before
+ if ($lastversion) {
+ uscan_verbose "Previous version downloaded: $lastversion";
+ } else {
+ uscan_verbose "Previous version not set, skipping";
+ }
+ } else {
+ uscan_verbose
+"Last orig.tar.* tarball version (from debian/changelog): $lastversion";
+ }
+
+ # And mangle it if requested
+ my $mangled_lastversion = $lastversion;
+ if (
+ mangle(
+ $watchfile, \$self->line,
+ 'dversionmangle:', \@{ $self->dversionmangle },
+ \$mangled_lastversion
+ )
+ ) {
+ return $self->status(1);
+ }
+
+ # Set $download_version etc. if already known
+ if ($self->versionmode eq 'ignore' and $self->config->download_version) {
+ uscan_verbose 'Ignore --download_version for component with "ignore"';
+ } elsif ($self->config->download_version) {
+ $self->shared->{download_version} = $self->config->download_version;
+ $self->shared->{download} = 2
+ if $self->shared->{download} == 1; # Change default 1 -> 2
+ $self->badversion(1);
+ uscan_verbose "Download the --download-version specified version: "
+ . "$self->{shared}->{download_version}";
+ } elsif ($self->config->download_debversion) {
+ $self->shared->{download_version} = $mangled_lastversion;
+ $self->shared->{download} = 2
+ if $self->shared->{download} == 1; # Change default 1 -> 2
+ $self->badversion(1);
+ uscan_verbose "Download the --download-debversion specified version "
+ . "(dversionmangled): $self->{shared}->{download_version}";
+ } elsif ($self->config->download_current_version) {
+ $self->shared->{download_version} = $mangled_lastversion;
+ $self->shared->{download} = 2
+ if $self->shared->{download} == 1; # Change default 1 -> 2
+ $self->badversion(1);
+ uscan_verbose
+ "Download the --download-current-version specified version: "
+ . "$self->{shared}->{download_version}";
+ } elsif ($self->versionmode eq 'same') {
+ unless (defined $self->shared->{common_newversion}) {
+ uscan_warn
+"Unable to set versionmode=prev for the line without opts=pgpmode=prev\n"
+ . " in $watchfile, skipping:\n"
+ . " $self->{line}";
+ return $self->status(1);
+ }
+ $self->shared->{download_version} = $self->shared->{common_newversion};
+ $self->shared->{download} = 2
+ if $self->shared->{download} == 1; # Change default 1 -> 2
+ $self->badversion(1);
+ uscan_verbose "Download secondary tarball with the matching version: "
+ . "$self->{shared}->{download_version}";
+ } elsif ($self->versionmode eq 'previous') {
+ unless ($self->pgpmode eq 'previous'
+ and defined $self->shared->{previous_newversion}) {
+ if ($self->shared->{download}) {
+ uscan_warn
+"Unable to set versionmode=prev for the line without opts=pgpmode=prev\n"
+ . " in $watchfile, skipping:\n $self->{line}";
+ } else {
+ uscan_verbose
+ "Nothing was downloaded before, skipping pgp check";
+ uscan_verbose " line " . $self->line;
+ }
+ return $self->status(1);
+ }
+ $self->shared->{download_version}
+ = $self->shared->{previous_newversion};
+ $self->shared->{download} = 2
+ if $self->shared->{download} == 1; # Change default 1 -> 2
+ $self->badversion(1);
+ uscan_verbose
+ "Download the signature file with the previous tarball's version:"
+ . " $self->{shared}->{download_version}";
+ } else {
+ # $options{'versionmode'} should be debian or ignore
+ if (defined $self->shared->{download_version}) {
+ uscan_die
+ "\$download_version defined after dversionmangle ... strange";
+ } else {
+ uscan_verbose "Last orig.tar.* tarball version (dversionmangled):"
+ . " $mangled_lastversion";
+ }
+ }
+
+ if ($self->watch_version != 1) {
+ if ($self->mode eq 'http' or $self->mode eq 'ftp') {
+ if ($base =~ m%^(\w+://[^/]+)%) {
+ $site = $1;
+ } else {
+ uscan_warn "Can't determine protocol and site in\n"
+ . " $watchfile, skipping:\n"
+ . " $self->{line}";
+ return $self->status(1);
+ }
+
+ # Find the path with the greatest version number matching the regex
+ $base
+ = recursive_regex_dir($self, $base,
+ $self->dirversionmangle, $watchfile, \$self->line,
+ $self->shared->{download_version});
+ if ($base eq '') {
+ return $self->status(1);
+ }
+
+ # We're going to make the pattern
+ # (?:(?:http://site.name)?/dir/path/)?base_pattern
+ # It's fine even for ftp sites
+ $basedir = $base;
+ $basedir =~ s%^\w+://[^/]+/%/%;
+ $basedir =~ s%/[^/]*(?:[#?].*)?$%/%;
+ $pattern
+ = "(?:(?:$site)?" . quotemeta($basedir) . ")?$filepattern";
+ } else {
+ # git tag match is simple
+ $site = $base; # dummy
+ $basedir = ''; # dummy
+ $pattern = $filepattern;
+ }
+ }
+
+ push @{ $self->sites }, $site;
+ push @{ $self->basedirs }, $basedir;
+ push @{ $self->patterns }, $pattern;
+
+ my $match = '';
+
+# Start Checking $site and look for $filepattern which is newer than $lastversion
+ uscan_debug "watch file has:\n"
+ . " \$base = $base\n"
+ . " \$filepattern = $filepattern\n"
+ . " \$lastversion = $lastversion\n"
+ . " \$action = $action\n"
+ . " mode = $self->{mode}\n"
+ . " pgpmode = $self->{pgpmode}\n"
+ . " versionmode = $self->{versionmode}\n"
+ . " \$site = $site\n"
+ . " \$basedir = $basedir";
+
+ $self->parse_result({
+ base => $base,
+ filepattern => $filepattern,
+ lastversion => $lastversion,
+ action => $action,
+ site => $site,
+ basedir => $basedir,
+ mangled_lastversion => $mangled_lastversion,
+ pattern => $pattern,
+ });
+
+# What is the most recent file, based on the filenames?
+# We first have to find the candidates, then we sort them using
+# Devscripts::Versort::upstream_versort (if it is real upstream version string) or
+# Devscripts::Versort::versort (if it is suffixed upstream version string)
+ return $self->status;
+}
+
+# II - search
+
+=head3 search()
+
+Search new file link and new version on the remote site using either:
+
+=over
+
+=item L<Devscripts::Uscan::http>::http_search()
+=item L<Devscripts::Uscan::ftp>::ftp_search()
+=item L<Devscripts::Uscan::git>::git_search()
+=item L<Devscripts::Uscan::svn>::svn_search()
+
+=back
+
+It populates B<$self-E<gt>search_result> hash ref with the following keys:
+
+=over
+
+=item B<newversion>: URL/tag pointing to the file to be downloaded
+=item B<newfile>: version number to be used for the downloaded file
+
+=back
+
+=cut
+
+sub search {
+ my ($self) = @_;
+ uscan_debug "line: search()";
+ my ($newversion, $newfile) = $self->_do('search');
+ unless ($newversion and $newfile) {
+ return $self->status(1);
+ }
+ $self->status and return $self->status;
+ uscan_verbose "Looking at \$base = $self->{parse_result}->{base} with\n"
+ . " \$filepattern = $self->{parse_result}->{filepattern} found\n"
+ . " \$newfile = $newfile\n"
+ . " \$newversion = $newversion\n"
+ . " \$lastversion = $self->{parse_result}->{mangled_lastversion}";
+ $self->search_result({
+ newversion => $newversion,
+ newfile => $newfile,
+ });
+
+ # The original version of the code didn't use (...) in the watch
+ # file to delimit the version number; thus if there is no (...)
+ # in the pattern, we will use the old heuristics, otherwise we
+ # use the new.
+
+ if ($self->style eq 'old') {
+
+ # Old-style heuristics
+ if ($newversion =~ /^\D*(\d+\.(?:\d+\.)*\d+)\D*$/) {
+ $self->search_result->{newversion} = $1;
+ } else {
+ uscan_warn <<"EOF";
+$progname warning: In $self->{watchfile}, couldn\'t determine a
+ pure numeric version number from the file name for watch line
+ $self->{line}
+ and file name $newfile
+ Please use a new style watch file instead!
+EOF
+ $self->status(1);
+ }
+ }
+ return $self->status;
+}
+
+# III - get_upstream_url
+
+=head3 get_upstream_url()
+
+Transform newfile/newversion into upstream url using either:
+
+=over
+
+=item L<Devscripts::Uscan::http>::http_upstream_url()
+=item L<Devscripts::Uscan::ftp>::ftp_upstream_url()
+=item L<Devscripts::Uscan::git>::git_upstream_url()
+=item L<Devscripts::Uscan::svn>::svn_upstream_url()
+
+=back
+
+Result is stored in B<$self-E<gt>upstream_url> accessor.
+
+=cut
+
+sub get_upstream_url {
+ my ($self) = @_;
+ uscan_debug "line: get_upstream_url()";
+ if ( $self->parse_result->{site} =~ m%^https?://%
+ and not $self->mode eq 'git'
+ and not $self->mode eq 'svn') {
+ $self->mode('http');
+ } elsif (not $self->mode) {
+ $self->mode('ftp');
+ }
+ $self->upstream_url($self->_do('upstream_url'));
+ $self->status and return $self->status;
+ uscan_verbose "Upstream URL(+tag) to download is identified as"
+ . " $self->{upstream_url}";
+ return $self->status;
+}
+
+# IV - get_newfile_base
+
+=head3 get_newfile_base()
+
+Calculates the filename (filenamemangled) for downloaded file using either:
+
+=over
+
+=item L<Devscripts::Uscan::http>::http_newfile_base()
+=item L<Devscripts::Uscan::ftp>::ftp_newfile_base()
+=item L<Devscripts::Uscan::git>::git_newfile_base()
+=item L<Devscripts::Uscan::svn>::svn_newfile_base()
+
+=back
+
+Result is stored in B<$self-E<gt>newfile_base> accessor.
+
+=cut
+
+sub get_newfile_base {
+ my ($self) = @_;
+ uscan_debug "line: get_newfile_base()";
+ $self->newfile_base($self->_do('newfile_base'));
+ return $self->status if ($self->status);
+ uscan_verbose
+ "Filename (filenamemangled) for downloaded file: $self->{newfile_base}";
+ return $self->status;
+}
+
+# V - cmp_versions
+
+=head3 cmp_versions()
+
+Compare available and local versions.
+
+=cut
+
+sub cmp_versions {
+ my ($self) = @_;
+ uscan_debug "line: cmp_versions()";
+ my $name = $self->component || $self->pkg;
+ my $mangled_lastversion = $self->parse_result->{mangled_lastversion};
+ unless (defined $self->shared->{common_newversion}) {
+ $self->shared->{common_newversion}
+ = $self->search_result->{newversion};
+ }
+
+ $dehs_tags->{'debian-uversion'} //= $self->parse_result->{lastversion};
+ $dehs_tags->{'debian-mangled-uversion'} //= $mangled_lastversion;
+ $dehs_tags->{'upstream-version'} //= $self->search_result->{newversion};
+ $dehs_tags->{'upstream-url'} //= $self->upstream_url;
+ $dehs_tags->{'component-name'} //= [];
+ $dehs_tags->{'component-upstream-version'} //= [];
+ if ($self->component) {
+ push @{ $dehs_tags->{'component-name'} }, $self->component;
+ push @{ $dehs_tags->{'component-debian-uversion'} },
+ $self->parse_result->{lastversion};
+ push @{ $dehs_tags->{'component-debian-mangled-uversion'} },
+ $mangled_lastversion;
+ push @{ $dehs_tags->{'component-upstream-version'} },
+ $self->search_result->{newversion};
+ push @{ $dehs_tags->{'component-upstream-url'} }, $self->upstream_url;
+ }
+
+ my $mangled_ver
+ = Dpkg::Version->new("1:${mangled_lastversion}-0", check => 0);
+ my $upstream_ver
+ = Dpkg::Version->new("1:$self->{search_result}->{newversion}-0",
+ check => 0);
+ my $compver;
+ if ($mangled_ver == $upstream_ver) {
+ $compver = 'same';
+ } elsif ($mangled_ver > $upstream_ver) {
+ $compver = 'older';
+ } else {
+ $compver = 'newer';
+ }
+
+ # Version dependent $download adjustment
+ if (defined $self->shared->{download_version}
+ and not $self->versionmode eq 'ignore') {
+
+ # Pretend to find a newer upstream version to exit without error
+ uscan_msg "Newest version of $name on remote site is "
+ . "$self->{search_result}->{newversion}, "
+ . "specified download version is $self->{shared}->{download_version}";
+ $found++ unless ($self->versionmode =~ /(?:same|ignore)/);
+ } elsif ($self->versionmode eq 'newer') {
+ if ($compver eq 'newer') {
+ uscan_msg "Newest version of $name on remote site is "
+ . "$self->{search_result}->{newversion}, "
+ . "local version is $self->{parse_result}->{mangled_lastversion}\n"
+ . (
+ $mangled_lastversion eq $self->parse_result->{lastversion}
+ ? ""
+ : " (mangled local version is $mangled_lastversion)\n"
+ );
+
+ # There's a newer upstream version available, which may already
+ # be on our system or may not be
+ uscan_msg " => Newer package available from:\n"
+ . " => $self->{upstream_url}";
+ $dehs_tags->{'status'} //= "newer package available";
+ $main::found++;
+ } elsif ($compver eq 'same') {
+ uscan_verbose "Newest version of $name on remote site is "
+ . $self->search_result->{newversion}
+ . ", local version is $self->{parse_result}->{mangled_lastversion}\n"
+ . (
+ $mangled_lastversion eq $self->parse_result->{lastversion}
+ ? ""
+ : " (mangled local version is $mangled_lastversion)\n"
+ );
+ uscan_verbose " => Package is up to date from:\n"
+ . " => $self->{upstream_url}";
+ $dehs_tags->{'status'} //= "up to date";
+ if ($self->shared->{download} > 1) {
+
+ # 2=force-download or 3=overwrite-download
+ uscan_verbose " => Forcing download as requested";
+ $main::found++;
+ } else {
+ # 0=no-download or 1=download
+ $self->shared->{download} = 0;
+ }
+ } else { # $compver eq 'old'
+ uscan_verbose "Newest version of $name on remote site is "
+ . $self->search_result->{newversion}
+ . ", local version is $self->{parse_result}->{mangled_lastversion}\n"
+ . (
+ $mangled_lastversion eq $self->parse_result->{lastversion}
+ ? ""
+ : " (mangled local version is $mangled_lastversion)\n"
+ );
+ uscan_verbose " => Only older package available from:\n"
+ . " => $self->{upstream_url}";
+ $dehs_tags->{'status'} //= "only older package available";
+ if ($self->shared->{download} > 1) {
+ uscan_verbose " => Forcing download as requested";
+ $main::found++;
+ } else {
+ $self->shared->{download} = 0;
+ }
+ }
+ } elsif ($self->versionmode eq 'ignore') {
+ uscan_msg "Newest version of $name on remote site is "
+ . $self->search_result->{newversion}
+ . ", ignore local version";
+ $dehs_tags->{'status'} //= "package available";
+ } else { # same/previous -- secondary-tarball or signature-file
+ uscan_die "strange ... <version> stanza = same/previous "
+ . "should have defined \$download_version";
+ }
+ return 0;
+}
+
+# VI - download_file_and_sig
+
+=head3 download_file_and_sig()
+
+Download file and, if available and needed, signature files.
+
+=cut
+
+my %already_downloaded;
+
+sub download_file_and_sig {
+ my ($self) = @_;
+ uscan_debug "line: download_file_and_sig()";
+ my $skip_git_vrfy;
+
+ # If we're not downloading or performing signature verification, we can
+ # stop here
+ if (!$self->shared->{download} || $self->shared->{signature} == -1) {
+ return 0;
+ }
+
+ # configure downloader
+ $self->downloader->git_export_all($self->gitexport eq 'all');
+
+ # 6.1 download tarball
+ my $download_available = 0;
+ my $upstream_base = basename($self->upstream_url);
+ $self->signature_available(0);
+ my $sigfile;
+ my $sigfile_base = $self->newfile_base;
+ uscan_die
+"Already downloaded a file named $self->{newfile_base}: use filenamemangle to avoid this"
+ if ($already_downloaded{ $self->{newfile_base} });
+ $already_downloaded{ $self->{newfile_base} } = 1;
+
+ if ($self->pgpmode ne 'previous') {
+
+ # try download package
+ if ($self->shared->{download} == 3
+ and -e "$self->{config}->{destdir}/$self->{newfile_base}") {
+ uscan_verbose
+"Downloading and overwriting existing file: $self->{newfile_base}";
+ $download_available = $self->downloader->download(
+ $self->upstream_url,
+ "$self->{config}->{destdir}/$self->{newfile_base}",
+ $self,
+ $self->parse_result->{base},
+ $self->pkg_dir,
+ $self->pkg,
+ $self->mode
+ );
+ if ($download_available) {
+ dehs_verbose
+ "Successfully downloaded package: $self->{newfile_base}\n";
+ } else {
+ dehs_verbose
+ "Failed to download upstream package: $upstream_base\n";
+ }
+ } elsif (-e "$self->{config}->{destdir}/$self->{newfile_base}") {
+ $download_available = 1;
+ dehs_verbose
+ "Not downloading, using existing file: $self->{newfile_base}\n";
+ $skip_git_vrfy = 1;
+ } elsif ($self->shared->{download} > 0) {
+ uscan_verbose "Downloading upstream package: $upstream_base";
+ $download_available = $self->downloader->download(
+ $self->upstream_url,
+ "$self->{config}->{destdir}/$self->{newfile_base}",
+ $self,
+ $self->parse_result->{base},
+ $self->pkg_dir,
+ $self->pkg,
+ $self->mode,
+ $self->gitrepo_dir,
+ );
+ if ($download_available) {
+ dehs_verbose
+ "Successfully downloaded upstream package: $upstream_base\n";
+ if (@{ $self->filenamemangle }) {
+ dehs_verbose
+ "Renamed upstream package to: $self->{newfile_base}\n";
+ }
+ } else {
+ dehs_verbose
+ "Failed to download upstream package: $upstream_base\n";
+ }
+ } else { # $download = 0,
+ $download_available = 0;
+ dehs_verbose "Not downloading upstream package: $upstream_base\n";
+ }
+ }
+ if ($self->pgpmode eq 'self') {
+ $sigfile_base =~ s/^(.*?)\.[^\.]+$/$1/; # drop .gpg, .asc, ...
+ if ($self->shared->{signature} == -1) {
+ uscan_warn("SKIP Checking OpenPGP signature (by request).\n");
+ $download_available
+ = -1; # can't proceed with self-signature archive
+ $self->signature_available(0);
+ } elsif (!$self->keyring) {
+ uscan_die("FAIL Checking OpenPGP signature (no keyring).\n");
+ } elsif ($download_available == 0) {
+ uscan_warn
+"FAIL Checking OpenPGP signature (no signed upstream tarball downloaded).";
+ return $self->status(1);
+ } else {
+ $self->keyring->verify(
+ "$self->{config}->{destdir}/$sigfile_base",
+ "$self->{config}->{destdir}/$self->{newfile_base}"
+ );
+
+# XXX FIXME XXX extract signature as detached signature to $self->{config}->{destdir}/$sigfile
+ $sigfile = $self->{newfile_base}; # XXX FIXME XXX place holder
+ $self->{newfile_base} = $sigfile_base;
+ $self->signature_available(3);
+ }
+ }
+ if ($self->pgpmode ne 'previous') {
+
+ # Decompress archive if requested and applicable
+ if ($download_available == 1 and $self->{'decompress'}) {
+ my $suffix_gz = $sigfile_base;
+ $suffix_gz =~ s/.*?(\.gz|\.xz|\.bz2|\.lzma|\.zstd?)?$/$1/;
+ if ($suffix_gz eq '.gz') {
+ if (-x '/bin/gunzip') {
+ uscan_exec('/bin/gunzip', "--keep",
+ "$self->{config}->{destdir}/$sigfile_base");
+ $sigfile_base =~ s/(.*?)\.gz/$1/;
+ } else {
+ uscan_warn("Please install gzip.\n");
+ return $self->status(1);
+ }
+ } elsif ($suffix_gz eq '.xz') {
+ if (-x '/usr/bin/unxz') {
+ uscan_exec('/usr/bin/unxz', "--keep",
+ "$self->{config}->{destdir}/$sigfile_base");
+ $sigfile_base =~ s/(.*?)\.xz/$1/;
+ } else {
+ uscan_warn("Please install xz-utils.\n");
+ return $self->status(1);
+ }
+ } elsif ($suffix_gz eq '.bz2') {
+ if (-x '/bin/bunzip2') {
+ uscan_exec('/bin/bunzip2', "--keep",
+ "$self->{config}->{destdir}/$sigfile_base");
+ $sigfile_base =~ s/(.*?)\.bz2/$1/;
+ } else {
+ uscan_warn("Please install bzip2.\n");
+ return $self->status(1);
+ }
+ } elsif ($suffix_gz eq '.lzma') {
+ if (-x '/usr/bin/unlzma') {
+ uscan_exec('/usr/bin/unlzma', "--keep",
+ "$self->{config}->{destdir}/$sigfile_base");
+ $sigfile_base =~ s/(.*?)\.lzma/$1/;
+ } else {
+ uscan_warn "Please install xz-utils or lzma.";
+ return $self->status(1);
+ }
+ } elsif ($suffix_gz =~ /.zstd?/) {
+ if (-x '/usr/bin/unzstd') {
+ uscan_exec('/usr/bin/unzstd', "--keep",
+ "$self->{config}->{destdir}/$sigfile_base");
+ $sigfile_base =~ s/(.*?)\.zst/$1/;
+ } else {
+ uscan_warn("Please install zstd.\n");
+ return $self->status(1);
+ }
+ } else {
+ uscan_die "Unknown type file to decompress: $sigfile_base";
+ }
+ }
+ }
+
+ # 6.2 download signature
+ my $pgpsig_url;
+ my $suffix_sig;
+ if (($self->pgpmode eq 'default' or $self->pgpmode eq 'auto')
+ and $self->shared->{signature} == 1) {
+ uscan_verbose
+"Start checking for common possible upstream OpenPGP signature files";
+ foreach $suffix_sig (qw(asc gpg pgp sig sign)) {
+ my $sigrequest = HTTP::Request->new(
+ 'HEAD' => "$self->{upstream_url}.$suffix_sig");
+ my $sigresponse
+ = $self->downloader->user_agent->request($sigrequest);
+ if ($sigresponse->is_success()) {
+ if ($self->pgpmode eq 'default') {
+ uscan_warn "Possible OpenPGP signature found at:\n"
+ . " $self->{upstream_url}.$suffix_sig\n"
+ . " * Add opts=pgpsigurlmangle=s/\$/.$suffix_sig/ or "
+ . "opts=pgpmode=auto to debian/watch\n"
+ . " * Add debian/upstream/signing-key.asc.\n"
+ . " See uscan(1) for more details";
+ $self->pgpmode('none');
+ } else { # auto
+ $self->pgpmode('mangle');
+ $self->pgpsigurlmangle(['s/$/.' . $suffix_sig . '/',]);
+ }
+ last;
+ }
+ }
+ uscan_verbose
+ "End checking for common possible upstream OpenPGP signature files";
+ $self->signature_available(0);
+ }
+ if ($self->pgpmode eq 'mangle') {
+ $pgpsig_url = $self->upstream_url;
+ if (
+ mangle(
+ $self->watchfile, \$self->line,
+ 'pgpsigurlmangle:', \@{ $self->pgpsigurlmangle },
+ \$pgpsig_url
+ )
+ ) {
+ return $self->status(1);
+ }
+ if (!$suffix_sig) {
+ $suffix_sig = $pgpsig_url;
+ $suffix_sig =~ s/^.*\.//;
+ if ($suffix_sig and $suffix_sig !~ m/^[a-zA-Z]+$/)
+ { # strange suffix
+ $suffix_sig = "pgp";
+ }
+ uscan_debug "Add $suffix_sig suffix based on $pgpsig_url.";
+ }
+ $sigfile = "$sigfile_base.$suffix_sig";
+ if ($self->shared->{signature} == 1) {
+ uscan_verbose "Downloading OpenPGP signature from:\n"
+ . " $pgpsig_url (pgpsigurlmangled)\n as $sigfile";
+ $self->signature_available(
+ $self->downloader->download(
+ $pgpsig_url, "$self->{config}->{destdir}/$sigfile",
+ $self, $self->parse_result->{base},
+ $self->pkg_dir, $self->pkg,
+ $self->mode
+ ));
+ } else { # -1, 0
+ uscan_verbose "Not downloading OpenPGP signature from:\n"
+ . " $pgpsig_url (pgpsigurlmangled)\n as $sigfile";
+ $self->signature_available(
+ (-e "$self->{config}->{destdir}/$sigfile") ? 1 : 0);
+ }
+ } elsif ($self->pgpmode eq 'previous') {
+ $pgpsig_url = $self->upstream_url;
+ $sigfile = $self->newfile_base;
+ if ($self->shared->{signature} == 1) {
+ uscan_verbose "Downloading OpenPGP signature from:\n"
+ . " $pgpsig_url (pgpmode=previous)\n as $sigfile";
+ $self->signature_available(
+ $self->downloader->download(
+ $pgpsig_url, "$self->{config}->{destdir}/$sigfile",
+ $self, $self->parse_result->{base},
+ $self->pkg_dir, $self->pkg,
+ $self->mode
+ ));
+ } else { # -1, 0
+ uscan_verbose "Not downloading OpenPGP signature from:\n"
+ . " $pgpsig_url (pgpmode=previous)\n as $sigfile";
+ $self->signature_available(
+ (-e "$self->{config}->{destdir}/$sigfile") ? 1 : 0);
+ }
+ $download_available = $self->shared->{previous_download_available};
+ $self->{newfile_base} = $self->shared->{previous_newfile_base};
+ $sigfile_base = $self->shared->{previous_sigfile_base};
+ uscan_verbose
+ "Use $self->{newfile_base} as upstream package (pgpmode=previous)";
+ }
+ $self->sigfile("$self->{config}->{destdir}/$sigfile") if ($sigfile);
+
+ # 6.3 verify signature
+ #
+ # 6.3.1 pgpmode
+ if ($self->pgpmode eq 'mangle' or $self->pgpmode eq 'previous') {
+ if ($self->shared->{signature} == -1) {
+ uscan_verbose("SKIP Checking OpenPGP signature (by request).\n");
+ } elsif (!$self->keyring) {
+ uscan_die("FAIL Checking OpenPGP signature (no keyring).\n");
+ } elsif ($download_available == 0) {
+ uscan_warn
+"FAIL Checking OpenPGP signature (no upstream tarball downloaded).";
+ return $self->status(1);
+ } elsif ($self->signature_available == 0) {
+ uscan_die(
+"FAIL Checking OpenPGP signature (no signature file downloaded).\n"
+ );
+ } else {
+ if ($self->shared->{signature} == 0) {
+ uscan_verbose "Use the existing file: $sigfile";
+ }
+ $self->keyring->verifyv(
+ "$self->{config}->{destdir}/$sigfile",
+ "$self->{config}->{destdir}/$sigfile_base"
+ );
+ }
+ $self->shared->{previous_newfile_base} = undef;
+ $self->shared->{previous_sigfile_base} = undef;
+ $self->shared->{previous_newversion} = undef;
+ $self->shared->{previous_download_available} = undef;
+ } elsif ($self->pgpmode eq 'none' or $self->pgpmode eq 'default') {
+ uscan_verbose "Missing OpenPGP signature.";
+ $self->shared->{previous_newfile_base} = undef;
+ $self->shared->{previous_sigfile_base} = undef;
+ $self->shared->{previous_newversion} = undef;
+ $self->shared->{previous_download_available} = undef;
+ } elsif ($self->pgpmode eq 'next') {
+ uscan_verbose
+ "Defer checking OpenPGP signature to the next watch line";
+ $self->shared->{previous_newfile_base} = $self->newfile_base;
+ $self->shared->{previous_sigfile_base} = $sigfile_base;
+ $self->shared->{previous_newversion}
+ = $self->search_result->{newversion};
+ $self->shared->{previous_download_available} = $download_available;
+ uscan_verbose "previous_newfile_base = $self->{newfile_base}";
+ uscan_verbose "previous_sigfile_base = $sigfile_base";
+ uscan_verbose
+ "previous_newversion = $self->{search_result}->{newversion}";
+ uscan_verbose "previous_download_available = $download_available";
+ } elsif ($self->pgpmode eq 'self') {
+ $self->shared->{previous_newfile_base} = undef;
+ $self->shared->{previous_sigfile_base} = undef;
+ $self->shared->{previous_newversion} = undef;
+ $self->shared->{previous_download_available} = undef;
+ } elsif ($self->pgpmode eq 'auto') {
+ uscan_verbose "Don't check OpenPGP signature";
+ } elsif ($self->pgpmode eq 'gittag') {
+ if ($skip_git_vrfy) {
+ uscan_warn "File already downloaded, skipping gpg verification";
+ } elsif (!$self->keyring) {
+ uscan_warn "No keyring file, skipping gpg verification";
+ return $self->status(1);
+ } else {
+ my ($gitrepo, $gitref) = split /[[:space:]]+/, $self->upstream_url;
+ $self->keyring->verify_git(
+ "$self->{downloader}->{destdir}/"
+ . $self->pkg
+ . "-temporary.$$.git",
+ $gitref, $self->downloader->git_upstream
+ );
+ }
+ } else {
+ uscan_warn "strange ... unknown pgpmode = $self->{pgpmode}";
+ return $self->status(1);
+ }
+ my $mangled_newversion = $self->search_result->{newversion};
+ if (
+ mangle(
+ $self->watchfile, \$self->line,
+ 'oversionmangle:', \@{ $self->oversionmangle },
+ \$mangled_newversion
+ )
+ ) {
+ return $self->status(1);
+ }
+
+ if (!$self->shared->{common_mangled_newversion}) {
+
+ # $mangled_newversion = version used for the new orig.tar.gz (a.k.a oversion)
+ uscan_verbose
+"New orig.tar.* tarball version (oversionmangled): $mangled_newversion";
+
+ # MUT package always use the same $common_mangled_newversion
+ # MUT disables repacksuffix so it is safe to have this before mk-origtargz
+ $self->shared->{common_mangled_newversion} = $mangled_newversion;
+ }
+ if ($self->pgpmode eq 'next') {
+ uscan_verbose "Read the next watch line (pgpmode=next)";
+ return 0;
+ }
+ if ($self->safe) {
+ uscan_verbose "SKIP generation of orig.tar.* "
+ . "and running of script/uupdate (--safe)";
+ return 0;
+ }
+ if ($download_available == 0) {
+ uscan_warn "No upstream tarball downloaded."
+ . " No further processing with mk_origtargz ...";
+ return $self->status(1);
+ }
+ if ($download_available == -1) {
+ uscan_warn "No upstream tarball unpacked from self signature file."
+ . " No further processing with mk_origtargz ...";
+ return $self->status(1);
+ }
+ if ($self->signature_available == 1 and $self->decompress) {
+ $self->signature_available(2);
+ }
+ $self->search_result->{sigfile} = $sigfile;
+ $self->must_download(1);
+ return $self->status;
+}
+
+# VII - mkorigtargz
+
+=head3 mkorigtargz()
+
+Call L<mk_origtargz> to build source tarball.
+
+=cut
+
+sub mkorigtargz {
+ my ($self) = @_;
+ uscan_debug "line: mkorigtargz()";
+ return 0 unless ($self->must_download);
+ my $mk_origtargz_out;
+ my $path = "$self->{config}->{destdir}/$self->{newfile_base}";
+ my $target = $self->newfile_base;
+ unless ($self->symlink eq "no" or $self->symlink eq "0") {
+ require Devscripts::MkOrigtargz;
+ if ($Devscripts::MkOrigtargz::found_comp) {
+ uscan_verbose
+ "Forcing compression to $Devscripts::MkOrigtargz::found_comp";
+ $self->repack(1);
+ }
+ @ARGV = ();
+ push @ARGV, "--package", $self->pkg;
+ push @ARGV, "--version", $self->shared->{common_mangled_newversion};
+ push @ARGV, '--repack-suffix', $self->repacksuffix
+ if $self->repacksuffix;
+ push @ARGV, "--rename" if $self->symlink eq "rename";
+ push @ARGV, "--copy" if $self->symlink eq "copy";
+ push @ARGV, "--signature", $self->signature_available
+ if ($self->signature_available != 0);
+ push @ARGV, "--signature-file",
+ "$self->{config}->{destdir}/$self->{search_result}->{sigfile}"
+ if ($self->signature_available != 0);
+ push @ARGV, "--repack" if $self->repack;
+ push @ARGV, "--force-repack" if $self->force_repack;
+ push @ARGV, "--component", $self->component
+ if $self->component;
+ push @ARGV, "--compression",
+ $Devscripts::MkOrigtargz::found_comp || $self->compression;
+ push @ARGV, "--directory", $self->config->destdir;
+ push @ARGV, "--copyright-file", "debian/copyright"
+ if ($self->config->exclusion && -e "debian/copyright");
+ push @ARGV, "--copyright-file", $self->config->copyright_file
+ if ($self->config->exclusion && $self->config->copyright_file);
+ push @ARGV, "--unzipopt", $self->unzipopt
+ if $self->unzipopt;
+ push @ARGV, $path;
+ my $tmp = $Devscripts::Output::die_on_error;
+
+ uscan_verbose "Launch mk-origtargz with options:\n "
+ . join(" ", @ARGV);
+ my $mk = Devscripts::MkOrigtargz->new;
+ $mk->do;
+ uscan_die "mk-origtargz failed" if ($mk->status);
+
+ $path = $mk->destfile_nice;
+ $target = basename($path);
+ $self->shared->{common_mangled_newversion} = $1
+ if $target =~ m/[^_]+_(.+)\.orig(?:-.+)?\.tar\.(?:gz|bz2|lzma|xz)$/;
+ uscan_verbose "New orig.tar.* tarball version (after mk-origtargz): "
+ . "$self->{shared}->{common_mangled_newversion}";
+ }
+ push @{ $self->shared->{origtars} }, $target;
+
+ if ($self->config->log) {
+
+ # Check pkg-ver.tar.gz and pkg_ver.orig.tar.gz
+ if (!$self->shared->{uscanlog}) {
+ $self->shared->{uscanlog}
+ = "$self->{config}->{destdir}/$self->{pkg}_$self->{shared}->{common_mangled_newversion}.uscan.log";
+ if (-e "$self->{shared}->{uscanlog}.old") {
+ unlink "$self->{shared}->{uscanlog}.old"
+ or uscan_die "Can\'t remove old backup log "
+ . "$self->{shared}->{uscanlog}.old: $!";
+ uscan_warn "Old backup uscan log found. "
+ . "Remove: $self->{shared}->{uscanlog}.old";
+ }
+ if (-e $self->shared->uscanlog) {
+ move($self->shared->uscanlog,
+ "$self->{shared}->{uscanlog}.old");
+ uscan_warn "Old uscan log found. "
+ . "Moved to: $self->{shared}->{uscanlog}.old";
+ }
+ open(USCANLOG, ">> $self->{shared}->{uscanlog}")
+ or uscan_die "$progname: could not open "
+ . "$self->{shared}->{uscanlog} for append: $!";
+ print USCANLOG "# uscan log\n";
+ } else {
+ open(USCANLOG, ">> $self->{shared}->{uscanlog}")
+ or uscan_die "$progname: could not open "
+ . "$self->{shared}->{uscanlog} for append: $!";
+ }
+ if ($self->symlink ne "rename") {
+ my $umd5sum = Digest::MD5->new;
+ my $omd5sum = Digest::MD5->new;
+ open(my $ufh, '<',
+ "$self->{config}->{destdir}/$self->{newfile_base}")
+ or uscan_die "Can't open '"
+ . "$self->{config}->{destdir}/$self->{newfile_base}" . "': $!";
+ open(my $ofh, '<', "$self->{config}->{destdir}/${target}")
+ or uscan_die
+ "Can't open '$self->{config}->{destdir}/${target}': $!";
+ $umd5sum->addfile($ufh);
+ $omd5sum->addfile($ofh);
+ close($ufh);
+ close($ofh);
+ my $umd5hex = $umd5sum->hexdigest;
+ my $omd5hex = $omd5sum->hexdigest;
+
+ if ($umd5hex eq $omd5hex) {
+ print USCANLOG
+ "# == $self->{newfile_base}\t-->\t${target}\t(same)\n";
+ } else {
+ print USCANLOG
+ "# !! $self->{newfile_base}\t-->\t${target}\t(changed)\n";
+ }
+ print USCANLOG "$umd5hex $self->{newfile_base}\n";
+ print USCANLOG "$omd5hex ${target}\n";
+ }
+ close USCANLOG
+ or uscan_die
+ "$progname: could not close $self->{shared}->{uscanlog} $!";
+ }
+
+ dehs_verbose "$mk_origtargz_out\n" if $mk_origtargz_out;
+ if ($self->component) {
+ push @{ $dehs_tags->{"component-target"} }, $target;
+ push @{ $dehs_tags->{"component-target-path"} }, $path;
+ } else {
+ $dehs_tags->{target} = $target;
+ $dehs_tags->{'target-path'} = $path;
+ }
+
+#######################################################################
+ # code 3.10: call uupdate
+#######################################################################
+ # Do whatever the user wishes to do
+ if ($self->parse_result->{action}) {
+ my @cmd = shellwords($self->parse_result->{action});
+
+ # script invocation changed in $watch_version=4
+ if ($self->watch_version > 3) {
+ if ($cmd[0] eq "uupdate") {
+ push @cmd, "-f";
+ if ($verbose) {
+ push @cmd, "--verbose";
+ }
+ if ($self->badversion) {
+ push @cmd, "-b";
+ }
+ }
+ push @cmd, "--upstream-version",
+ $self->shared->{common_mangled_newversion};
+ if (abs_path($self->{config}->{destdir}) ne abs_path("..")) {
+ foreach my $origtar (@{ $self->shared->{origtars} }) {
+ copy(catfile($self->{config}->{destdir}, $origtar),
+ catfile("..", $origtar));
+ }
+ }
+ } elsif ($self->watch_version > 1) {
+
+ # Any symlink requests are already handled by uscan
+ if ($cmd[0] eq "uupdate") {
+ push @cmd, "--no-symlink";
+ if ($verbose) {
+ push @cmd, "--verbose";
+ }
+ if ($self->badversion) {
+ push @cmd, "-b";
+ }
+ }
+ push @cmd, "--upstream-version",
+ $self->shared->{common_mangled_newversion}, $path;
+ } else {
+ push @cmd, $path, $self->shared->{common_mangled_newversion};
+ }
+ my $actioncmd = join(" ", @cmd);
+ my $actioncmdmsg;
+ spawn(exec => \@cmd, wait_child => 1, to_string => \$actioncmdmsg);
+ local $, = ' ';
+ dehs_verbose "Executing user specified script:\n @cmd\n"
+ . $actioncmdmsg;
+ }
+ $self->destfile($path);
+
+ return 0;
+}
+
+# VIII - clean
+
+=head3 clean()
+
+Clean temporary files using either:
+
+=over
+
+=item L<Devscripts::Uscan::http>::http_clean()
+=item L<Devscripts::Uscan::ftp>::ftp_clean()
+=item L<Devscripts::Uscan::git>::git_clean()
+=item L<Devscripts::Uscan::svn>::svn_clean()
+
+=back
+
+=cut
+
+sub clean {
+ my ($self) = @_;
+ $self->_do('clean');
+}
+
+# Internal sub to call sub modules (git, http,...)
+sub _do {
+ my ($self, $sub) = @_;
+ my $mode = $self->mode;
+ $mode =~ s/git-dumb/git/;
+ $sub = $mode . "_$sub";
+ with("Devscripts::Uscan::$mode") unless ($self->can($sub));
+ if ($@) {
+ uscan_warn "Unknown '$mode' mode set in $self->{watchfile} ($@)";
+ $self->status(1);
+ }
+ return $self->$sub;
+}
+
+1;
+
+=head1 SEE ALSO
+
+L<uscan>, L<Devscripts::Uscan::WatchFile>, L<Devscripts::Uscan::Config>
+
+=head1 AUTHOR
+
+B<uscan> was originally written by Christoph Lameter
+E<lt>clameter@debian.orgE<gt> (I believe), modified by Julian Gilbey
+E<lt>jdg@debian.orgE<gt>. HTTP support was added by Piotr Roszatycki
+E<lt>dexter@debian.orgE<gt>. B<uscan> was rewritten in Perl by Julian Gilbey.
+Xavier Guimard E<lt>yadd@debian.orgE<gt> rewrote uscan in object
+oriented Perl.
+
+=head1 COPYRIGHT AND LICENSE
+
+Copyright 2002-2006 by Julian Gilbey <jdg@debian.org>,
+2018 by Xavier Guimard <yadd@debian.org>
+
+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 2 of the License, or
+(at your option) any later version.
+
+=cut
diff --git a/lib/Devscripts/Uscan/_vcs.pm b/lib/Devscripts/Uscan/_vcs.pm
new file mode 100644
index 0000000..b66f560
--- /dev/null
+++ b/lib/Devscripts/Uscan/_vcs.pm
@@ -0,0 +1,93 @@
+# Common sub shared between git and svn
+package Devscripts::Uscan::_vcs;
+
+use strict;
+use Devscripts::Uscan::Output;
+use Devscripts::Uscan::Utils;
+use Exporter 'import';
+use File::Basename;
+
+our @EXPORT = ('get_refs');
+
+our $progname = basename($0);
+
+sub _vcs_newfile_base {
+ my ($self) = @_;
+ my $zsuffix = get_suffix($self->compression);
+ my $newfile_base
+ = "$self->{pkg}-$self->{search_result}->{newversion}.tar.$zsuffix";
+ return $newfile_base;
+}
+
+sub get_refs {
+ my ($self, $command, $ref_pattern, $package) = @_;
+ my @command = @$command;
+ my ($newfile, $newversion);
+ {
+ local $, = ' ';
+ uscan_verbose "Execute: @command";
+ }
+ open(REFS, "-|", @command)
+ || uscan_die "$progname: you must have the $package package installed";
+ my @refs;
+ my $ref;
+ my $version;
+ while (<REFS>) {
+ chomp;
+ uscan_debug "$_";
+ if ($_ =~ $ref_pattern) {
+ $ref = $1;
+ foreach my $_pattern (@{ $self->patterns }) {
+ $version = join(".",
+ map { $_ if defined($_) } $ref =~ m&^$_pattern$&);
+ if (
+ mangle(
+ $self->watchfile, \$self->line,
+ 'uversionmangle:', \@{ $self->uversionmangle },
+ \$version
+ )
+ ) {
+ return undef;
+ }
+ push @refs, [$version, $ref];
+ }
+ }
+ }
+ if (@refs) {
+ @refs = Devscripts::Versort::upstream_versort(@refs);
+ my $msg = "Found the following matching refs:\n";
+ foreach my $ref (@refs) {
+ $msg .= " $$ref[1] ($$ref[0])\n";
+ }
+ uscan_verbose "$msg";
+ if ($self->shared->{download_version}
+ and not $self->versionmode eq 'ignore') {
+
+# extract ones which has $version in the above loop matched with $download_version
+ my @vrefs
+ = grep { $$_[0] eq $self->shared->{download_version} } @refs;
+ if (@vrefs) {
+ ($newversion, $newfile) = @{ $vrefs[0] };
+ } else {
+ uscan_warn
+ "$progname warning: In $self->{watchfile} no matching"
+ . " refs for version "
+ . $self->shared->{download_version}
+ . " in watch line\n "
+ . $self->{line};
+ return undef;
+ }
+
+ } else {
+ ($newversion, $newfile) = @{ $refs[0] };
+ }
+ } else {
+ uscan_warn "$progname warning: In $self->{watchfile},\n"
+ . " no matching refs for watch line\n"
+ . " $self->{line}";
+ return undef;
+ }
+ return ($newversion, $newfile);
+}
+
+1;
diff --git a/lib/Devscripts/Uscan/_xtp.pm b/lib/Devscripts/Uscan/_xtp.pm
new file mode 100644
index 0000000..092cb52
--- /dev/null
+++ b/lib/Devscripts/Uscan/_xtp.pm
@@ -0,0 +1,90 @@
+# Common sub shared between http and ftp
+package Devscripts::Uscan::_xtp;
+
+use strict;
+use File::Basename;
+use Exporter 'import';
+use Devscripts::Uscan::Output;
+use Devscripts::Uscan::Utils;
+
+our @EXPORT = ('partial_version');
+
+sub _xtp_newfile_base {
+ my ($self) = @_;
+ my $newfile_base;
+ if (@{ $self->filenamemangle }) {
+
+ # HTTP or FTP site (with filenamemangle)
+ if ($self->versionless) {
+ $newfile_base = $self->upstream_url;
+ } else {
+ $newfile_base = $self->search_result->{newfile};
+ }
+ my $cmp = $newfile_base;
+ uscan_verbose "Matching target for filenamemangle: $newfile_base";
+ if (
+ mangle(
+ $self->watchfile, \$self->line,
+ 'filenamemangle:', \@{ $self->filenamemangle },
+ \$newfile_base
+ )
+ ) {
+ $self->status(1);
+ return undef;
+ }
+ if ($newfile_base =~ m/^(?:https?|ftp):/) {
+ $newfile_base = basename($newfile_base);
+ }
+ if ($cmp eq $newfile_base) {
+ uscan_die "filenamemangle failed for $cmp";
+ }
+ unless ($self->search_result->{newversion}) {
+
+ # uversionmanglesd version is '', make best effort to set it
+ $newfile_base
+ =~ m/^.+?[-_]?(\d[\-+\.:\~\da-zA-Z]*)(?:\.tar\.(gz|bz2|xz|zstd?)|\.zip)$/i;
+ $self->search_result->{newversion} = $1;
+ unless ($self->search_result->{newversion}) {
+ uscan_warn
+"Fix filenamemangle to produce a filename with the correct version";
+ $self->status(1);
+ return undef;
+ }
+ uscan_verbose
+"Newest upstream tarball version from the filenamemangled filename: $self->{search_result}->{newversion}";
+ }
+ } else {
+ # HTTP or FTP site (without filenamemangle)
+ $newfile_base = basename($self->search_result->{newfile});
+ if ($self->mode eq 'http') {
+
+ # Remove HTTP header trash
+ $newfile_base =~ s/[\?#].*$//; # PiPy
+ # just in case this leaves us with nothing
+ if ($newfile_base eq '') {
+ uscan_warn
+"No good upstream filename found after removing tailing ?... and #....\n Use filenamemangle to fix this.";
+ $self->status(1);
+ return undef;
+ }
+ }
+ }
+ return $newfile_base;
+}
+
+sub partial_version {
+ my ($download_version) = @_;
+ my ($d1, $d2, $d3);
+ if (defined $download_version) {
+ uscan_verbose "download version requested: $download_version";
+ if ($download_version
+ =~ m/^([-~\+\w]+)(\.[-~\+\w]+)?(\.[-~\+\w]+)?(\.[-~\+\w]+)?$/) {
+ $d1 = "$1" if defined $1;
+ $d2 = "$1$2" if defined $2;
+ $d3 = "$1$2$3" if defined $3;
+ }
+ }
+ return ($d1, $d2, $d3);
+}
+
+1;
diff --git a/lib/Devscripts/Uscan/ftp.pm b/lib/Devscripts/Uscan/ftp.pm
new file mode 100644
index 0000000..5a24d8a
--- /dev/null
+++ b/lib/Devscripts/Uscan/ftp.pm
@@ -0,0 +1,280 @@
+package Devscripts::Uscan::ftp;
+
+use strict;
+use Cwd qw/abs_path/;
+use Devscripts::Uscan::Output;
+use Devscripts::Uscan::Utils;
+use Devscripts::Uscan::_xtp;
+use Moo::Role;
+
+#######################################################################
+# search $newfile $newversion (ftp mode)
+#######################################################################
+sub ftp_search {
+ my ($self) = @_;
+
+ # FTP site
+ uscan_verbose "Requesting URL:\n $self->{parse_result}->{base}";
+ my $request = HTTP::Request->new('GET', $self->parse_result->{base});
+ my $response = $self->downloader->user_agent->request($request);
+ if (!$response->is_success) {
+ uscan_warn
+"In watch file $self->{watchfile}, reading FTP directory\n $self->{parse_result}->{base} failed: "
+ . $response->status_line . "";
+ return undef;
+ }
+
+ my $content = $response->content;
+ uscan_extra_debug
+ "received content:\n$content\n[End of received content] by FTP";
+
+ # FTP directory listings either look like:
+ # info info ... info filename [ -> linkname]
+ # or they're HTMLised (if they've been through an HTTP proxy)
+ # so we may have to look for <a href="filename"> type patterns
+ uscan_verbose "matching pattern $self->{parse_result}->{pattern}";
+ my (@files);
+
+ # We separate out HTMLised listings from standard listings, so
+ # that we can target our search correctly
+ if ($content =~ /<\s*a\s+[^>]*href/i) {
+ uscan_verbose "HTMLized FTP listing by the HTTP proxy";
+ while ($content
+ =~ m/(?:<\s*a\s+[^>]*href\s*=\s*\")((?-i)$self->{parse_result}->{pattern})\"/gi
+ ) {
+ my $file = fix_href($1);
+ my $mangled_version
+ = join(".", $file =~ m/^$self->{parse_result}->{pattern}$/);
+ if (
+ mangle(
+ $self->watchfile, \$self->line,
+ 'uversionmangle:', \@{ $self->uversionmangle },
+ \$mangled_version
+ )
+ ) {
+ return undef;
+ }
+ my $match = '';
+ if (defined $self->shared->{download_version}
+ and not $self->versionmode eq 'ignore') {
+ if ($mangled_version eq $self->shared->{download_version}) {
+ $match = "matched with the download version";
+ }
+ }
+ my $priority = $mangled_version . '-' . get_priority($file);
+ push @files, [$priority, $mangled_version, $file, $match];
+ }
+ } else {
+ uscan_verbose "Standard FTP listing.";
+
+ # they all look like:
+ # info info ... info filename [ -> linkname]
+ for my $ln (split(/\n/, $content)) {
+ $ln =~ s/^d.*$//; # FTP listing of directory, '' skipped
+ $ln =~ s/\s+->\s+\S+$//; # FTP listing for link destination
+ $ln =~ s/^.*\s(\S+)$/$1/; # filename only
+ if ($ln and $ln =~ m/^($self->{parse_result}->{filepattern})$/) {
+ my $file = $1;
+ my $mangled_version = join(".",
+ $file =~ m/^$self->{parse_result}->{filepattern}$/);
+ if (
+ mangle(
+ $self->watchfile, \$self->line,
+ 'uversionmangle:', \@{ $self->uversionmangle },
+ \$mangled_version
+ )
+ ) {
+ return undef;
+ }
+ my $match = '';
+ if (defined $self->shared->{download_version}) {
+ if ($mangled_version eq $self->shared->{download_version})
+ {
+ $match = "matched with the download version";
+ }
+ }
+ my $priority = $mangled_version . '-' . get_priority($file);
+ push @files, [$priority, $mangled_version, $file, $match];
+ }
+ }
+ }
+ if (@files) {
+ @files = Devscripts::Versort::versort(@files);
+ my $msg
+ = "Found the following matching files on the web page (newest first):\n";
+ foreach my $file (@files) {
+ $msg .= " $$file[2] ($$file[1]) index=$$file[0] $$file[3]\n";
+ }
+ uscan_verbose $msg;
+ }
+ my ($newversion, $newfile);
+ if (defined $self->shared->{download_version}) {
+
+ # extract ones which has $match in the above loop defined
+ my @vfiles = grep { $$_[3] } @files;
+ if (@vfiles) {
+ (undef, $newversion, $newfile, undef) = @{ $vfiles[0] };
+ } else {
+ uscan_warn
+"In $self->{watchfile} no matching files for version $self->{shared}->{download_version}"
+ . " in watch line\n $self->{line}";
+ return undef;
+ }
+ } else {
+ if (@files) {
+ (undef, $newversion, $newfile, undef) = @{ $files[0] };
+ } else {
+ uscan_warn
+"In $self->{watchfile} no matching files for watch line\n $self->{line}";
+ return undef;
+ }
+ }
+ return ($newversion, $newfile);
+}
+
+sub ftp_upstream_url {
+ my ($self) = @_;
+ return $self->parse_result->{base} . $self->search_result->{newfile};
+}
+
+*ftp_newfile_base = \&Devscripts::Uscan::_xtp::_xtp_newfile_base;
+
+sub ftp_newdir {
+ my ($line, $site, $dir, $pattern, $dirversionmangle, $watchfile,
+ $lineptr, $download_version)
+ = @_;
+ my $downloader = $line->downloader;
+
+ my ($request, $response, $newdir);
+ my ($download_version_short1, $download_version_short2,
+ $download_version_short3)
+ = partial_version($download_version);
+ my $base = $site . $dir;
+ $request = HTTP::Request->new('GET', $base);
+ $response = $downloader->user_agent->request($request);
+ if (!$response->is_success) {
+ uscan_warn
+ "In watch file $watchfile, reading webpage\n $base failed: "
+ . $response->status_line;
+ return '';
+ }
+
+ my $content = $response->content;
+ uscan_extra_debug
+ "received content:\n$content\n[End of received content] by FTP";
+
+ # FTP directory listings either look like:
+ # info info ... info filename [ -> linkname]
+ # or they're HTMLised (if they've been through an HTTP proxy)
+ # so we may have to look for <a href="filename"> type patterns
+ uscan_verbose "matching pattern $pattern";
+ my (@dirs);
+ my $match = '';
+
+ # We separate out HTMLised listings from standard listings, so
+ # that we can target our search correctly
+ if ($content =~ /<\s*a\s+[^>]*href/i) {
+ uscan_verbose "HTMLized FTP listing by the HTTP proxy";
+ while (
+ $content =~ m/(?:<\s*a\s+[^>]*href\s*=\s*\")((?-i)$pattern)\"/gi) {
+ my $dir = $1;
+ uscan_verbose "Matching target for dirversionmangle: $dir";
+ my $mangled_version = join(".", $dir =~ m/^$pattern$/);
+ if (
+ mangle(
+ $watchfile, $lineptr,
+ 'dirversionmangle:', \@{$dirversionmangle},
+ \$mangled_version
+ )
+ ) {
+ return 1;
+ }
+ $match = '';
+ if (defined $download_version
+ and $mangled_version eq $download_version) {
+ $match = "matched with the download version";
+ }
+ if (defined $download_version_short3
+ and $mangled_version eq $download_version_short3) {
+ $match = "matched with the download version (partial 3)";
+ }
+ if (defined $download_version_short2
+ and $mangled_version eq $download_version_short2) {
+ $match = "matched with the download version (partial 2)";
+ }
+ if (defined $download_version_short1
+ and $mangled_version eq $download_version_short1) {
+ $match = "matched with the download version (partial 1)";
+ }
+ push @dirs, [$mangled_version, $dir, $match];
+ }
+ } else {
+ # they all look like:
+ # info info ... info filename [ -> linkname]
+ uscan_verbose "Standard FTP listing.";
+ foreach my $ln (split(/\n/, $content)) {
+ $ln =~ s/^-.*$//; # FTP listing of file, '' skipped
+ $ln =~ s/\s+->\s+\S+$//; # FTP listing for link destination
+ $ln =~ s/^.*\s(\S+)$/$1/; # filename only
+ if ($ln =~ m/^($pattern)(\s+->\s+\S+)?$/) {
+ my $dir = $1;
+ uscan_verbose "Matching target for dirversionmangle: $dir";
+ my $mangled_version = join(".", $dir =~ m/^$pattern$/);
+ if (
+ mangle(
+ $watchfile, $lineptr,
+ 'dirversionmangle:', \@{$dirversionmangle},
+ \$mangled_version
+ )
+ ) {
+ return 1;
+ }
+ $match = '';
+ if (defined $download_version
+ and $mangled_version eq $download_version) {
+ $match = "matched with the download version";
+ }
+ if (defined $download_version_short3
+ and $mangled_version eq $download_version_short3) {
+ $match = "matched with the download version (partial 3)";
+ }
+ if (defined $download_version_short2
+ and $mangled_version eq $download_version_short2) {
+ $match = "matched with the download version (partial 2)";
+ }
+ if (defined $download_version_short1
+ and $mangled_version eq $download_version_short1) {
+ $match = "matched with the download version (partial 1)";
+ }
+ push @dirs, [$mangled_version, $dir, $match];
+ }
+ }
+ }
+
+ # extract ones which has $match in the above loop defined
+ my @vdirs = grep { $$_[2] } @dirs;
+ if (@vdirs) {
+ @vdirs = Devscripts::Versort::upstream_versort(@vdirs);
+ $newdir = $vdirs[0][1];
+ }
+ if (@dirs) {
+ @dirs = Devscripts::Versort::upstream_versort(@dirs);
+ my $msg
+ = "Found the following matching FTP directories (newest first):\n";
+ foreach my $dir (@dirs) {
+ $msg .= " $$dir[1] ($$dir[0]) $$dir[2]\n";
+ }
+ uscan_verbose $msg;
+ $newdir //= $dirs[0][1];
+ } else {
+ uscan_warn
+ "In $watchfile no matching dirs for pattern\n $base$pattern";
+ $newdir = '';
+ }
+ return $newdir;
+}
+
+# Nothing to clean here
+sub ftp_clean { 0 }
+
+1;
diff --git a/lib/Devscripts/Uscan/git.pm b/lib/Devscripts/Uscan/git.pm
new file mode 100644
index 0000000..843e7c0
--- /dev/null
+++ b/lib/Devscripts/Uscan/git.pm
@@ -0,0 +1,172 @@
+package Devscripts::Uscan::git;
+
+use strict;
+use Cwd qw/abs_path/;
+use Devscripts::Uscan::Output;
+use Devscripts::Uscan::Utils;
+use Devscripts::Uscan::_vcs;
+use Dpkg::IPC;
+use File::Path 'remove_tree';
+use Moo::Role;
+
+######################################################
+# search $newfile $newversion (git mode/versionless)
+######################################################
+sub git_search {
+ my ($self) = @_;
+ my ($newfile, $newversion);
+ if ($self->versionless) {
+ $newfile = $self->parse_result->{filepattern}; # HEAD or heads/<branch>
+ if ($self->pretty eq 'describe') {
+ $self->gitmode('full');
+ }
+ if ( $self->gitmode eq 'shallow'
+ and $self->parse_result->{filepattern} eq 'HEAD') {
+ uscan_exec(
+ 'git',
+ 'clone',
+ '--quiet',
+ '--bare',
+ '--depth=1',
+ $self->parse_result->{base},
+ "$self->{downloader}->{destdir}/" . $self->gitrepo_dir
+ );
+ $self->downloader->gitrepo_state(1);
+ } elsif ($self->gitmode eq 'shallow'
+ and $self->parse_result->{filepattern} ne 'HEAD')
+ { # heads/<branch>
+ $newfile =~ s&^heads/&&; # Set to <branch>
+ uscan_exec(
+ 'git',
+ 'clone',
+ '--quiet',
+ '--bare',
+ '--depth=1',
+ '-b',
+ "$newfile",
+ $self->parse_result->{base},
+ "$self->{downloader}->{destdir}/" . $self->gitrepo_dir
+ );
+ $self->downloader->gitrepo_state(1);
+ } else {
+ uscan_exec(
+ 'git', 'clone', '--quiet', '--bare',
+ $self->parse_result->{base},
+ "$self->{downloader}->{destdir}/" . $self->gitrepo_dir
+ );
+ $self->downloader->gitrepo_state(2);
+ }
+ if ($self->pretty eq 'describe') {
+
+ # use unannotated tags to be on safe side
+ spawn(
+ exec => [
+ 'git',
+"--git-dir=$self->{downloader}->{destdir}/$self->{gitrepo_dir}",
+ 'describe',
+ '--tags'
+ ],
+ wait_child => 1,
+ to_string => \$newversion
+ );
+ $newversion =~ s/-/./g;
+ chomp($newversion);
+ if (
+ mangle(
+ $self->watchfile, \$self->line,
+ 'uversionmangle:', \@{ $self->uversionmangle },
+ \$newversion
+ )
+ ) {
+ return undef;
+ }
+ } else {
+ my $tmp = $ENV{TZ};
+ $ENV{TZ} = 'UTC';
+ spawn(
+ exec => [
+ 'git',
+"--git-dir=$self->{downloader}->{destdir}/$self->{gitrepo_dir}",
+ 'log',
+ '-1',
+ "--date=format-local:$self->{date}",
+ "--pretty=$self->{pretty}"
+ ],
+ wait_child => 1,
+ to_string => \$newversion
+ );
+ $ENV{TZ} = $tmp;
+ chomp($newversion);
+ }
+ }
+ ################################################
+ # search $newfile $newversion (git mode w/tag)
+ ################################################
+ elsif ($self->mode eq 'git') {
+ my @args = ('ls-remote', $self->parse_result->{base});
+ # Try to use local upstream branch if available
+ if (-d '.git') {
+ my $out;
+ eval {
+ spawn(
+ exec => ['git', 'remote', '--verbose', 'show'],
+ wait_child => 1,
+ to_string => \$out
+ );
+ };
+ # Check if git repo found in debian/watch exists in
+ # `git remote show` output
+ if ($out and $out =~ /^(\S+)\s+\Q$self->{parse_result}->{base}\E/m)
+ {
+ $self->downloader->git_upstream($1);
+ uscan_warn
+ "Using $self->{downloader}->{git_upstream} remote origin";
+ # Found, launch a "fetch" to be up to date
+ spawn(
+ exec => ['git', 'fetch', $self->downloader->git_upstream],
+ wait_child => 1
+ );
+ @args = ('show-ref');
+ }
+ }
+ ($newversion, $newfile)
+ = get_refs($self, ['git', @args], qr/^\S+\s+([^\^\{\}]+)$/, 'git');
+ return undef if !defined $newversion;
+ }
+ return ($newversion, $newfile);
+}
+
+sub git_upstream_url {
+ my ($self) = @_;
+ my $upstream_url
+ = $self->parse_result->{base} . ' ' . $self->search_result->{newfile};
+ return $upstream_url;
+}
+
+*git_newfile_base = \&Devscripts::Uscan::_vcs::_vcs_newfile_base;
+
+sub git_clean {
+ my ($self) = @_;
+
+ # If git cloned repo exists and not --debug ($verbose=2) -> remove it
+ if ( $self->downloader->gitrepo_state > 0
+ and $verbose < 2
+ and !$self->downloader->git_upstream) {
+ my $err;
+ uscan_verbose "Removing git repo ($self->{downloader}->{destdir}/"
+ . $self->gitrepo_dir . ")";
+ remove_tree "$self->{downloader}->{destdir}/" . $self->gitrepo_dir,
+ { error => \$err };
+ if (@$err) {
+ local $, = "\n\t";
+ uscan_warn "Errors during git repo clean:\n\t@$err";
+ }
+ $self->downloader->gitrepo_state(0);
+ } else {
+ uscan_debug "Keep git repo ($self->{downloader}->{destdir}/"
+ . $self->gitrepo_dir . ")";
+ }
+ return 0;
+}
+
+1;
diff --git a/lib/Devscripts/Uscan/http.pm b/lib/Devscripts/Uscan/http.pm
new file mode 100644
index 0000000..0da9798
--- /dev/null
+++ b/lib/Devscripts/Uscan/http.pm
@@ -0,0 +1,510 @@
+package Devscripts::Uscan::http;
+
+use strict;
+use Cwd qw/abs_path/;
+use Devscripts::Uscan::Output;
+use Devscripts::Uscan::Utils;
+use Devscripts::Uscan::_xtp;
+use Moo::Role;
+
+*http_newfile_base = \&Devscripts::Uscan::_xtp::_xtp_newfile_base;
+
+##################################
+# search $newversion (http mode)
+##################################
+
+#returns (\@patterns, \@base_sites, \@base_dirs)
+sub handle_redirection {
+ my ($self, $pattern, @additional_bases) = @_;
+ my @redirections = @{ $self->downloader->user_agent->get_redirections };
+ my (@patterns, @base_sites, @base_dirs);
+
+ uscan_verbose "redirections: @redirections" if @redirections;
+
+ foreach my $_redir (@redirections, @additional_bases) {
+ my $base_dir = $_redir;
+
+ $base_dir =~ s%^\w+://[^/]+/%/%;
+ $base_dir =~ s%/[^/]*(?:[#?].*)?$%/%;
+ if ($_redir =~ m%^(\w+://[^/]+)%) {
+ my $base_site = $1;
+
+ push @patterns,
+ quotemeta($base_site) . quotemeta($base_dir) . "$pattern";
+ push @base_sites, $base_site;
+ push @base_dirs, $base_dir;
+
+ # remove the filename, if any
+ my $base_dir_orig = $base_dir;
+ $base_dir =~ s%/[^/]*$%/%;
+ if ($base_dir ne $base_dir_orig) {
+ push @patterns,
+ quotemeta($base_site) . quotemeta($base_dir) . "$pattern";
+ push @base_sites, $base_site;
+ push @base_dirs, $base_dir;
+ }
+ }
+ }
+ return (\@patterns, \@base_sites, \@base_dirs);
+}
+
+sub http_search {
+ my ($self) = @_;
+
+ # $content: web page to be scraped to find the URLs to be downloaded
+ if ($self->{parse_result}->{base} =~ /^https/ and !$self->downloader->ssl)
+ {
+ uscan_die
+"you must have the liblwp-protocol-https-perl package installed\nto use https URLs";
+ }
+ uscan_verbose "Requesting URL:\n $self->{parse_result}->{base}";
+ my $request = HTTP::Request->new('GET', $self->parse_result->{base});
+ foreach my $k (keys %{ $self->downloader->headers }) {
+ if ($k =~ /^(.*?)@(.*)$/) {
+ my $baseUrl = $1;
+ my $hdr = $2;
+ if ($self->parse_result->{base} =~ m#^\Q$baseUrl\E(?:/.*)?$#) {
+ $request->header($hdr => $self->headers->{$k});
+ uscan_verbose "Set per-host custom header $hdr for "
+ . $self->parse_result->{base};
+ } else {
+ uscan_debug
+ "$self->parse_result->{base} does not start with $1";
+ }
+ } else {
+ uscan_warn "Malformed http-header: $k";
+ }
+ }
+ $request->header('Accept-Encoding' => 'gzip');
+ $request->header('Accept' => '*/*');
+ my $response = $self->downloader->user_agent->request($request);
+ if (!$response->is_success) {
+ uscan_warn
+"In watchfile $self->{watchfile}, reading webpage\n $self->{parse_result}->{base} failed: "
+ . $response->status_line;
+ return undef;
+ }
+
+ my ($patterns, $base_sites, $base_dirs)
+ = handle_redirection($self, $self->{parse_result}->{filepattern});
+ push @{ $self->patterns }, @$patterns;
+ push @{ $self->sites }, @$base_sites;
+ push @{ $self->basedirs }, @$base_dirs;
+
+ my $content = $response->decoded_content;
+ uscan_extra_debug
+ "received content:\n$content\n[End of received content] by HTTP";
+
+ my @hrefs;
+ if (!$self->searchmode or $self->searchmode eq 'html') {
+ @hrefs = $self->html_search($content, $self->patterns);
+ } elsif ($self->searchmode eq 'plain') {
+ @hrefs = $self->plain_search($content);
+ } else {
+ uscan_warn 'Unknown searchmode "' . $self->searchmode . '", skipping';
+ return undef;
+ }
+
+ if (@hrefs) {
+ @hrefs = Devscripts::Versort::versort(@hrefs);
+ my $msg
+ = "Found the following matching hrefs on the web page (newest first):\n";
+ foreach my $href (@hrefs) {
+ $msg .= " $$href[2] ($$href[1]) index=$$href[0] $$href[3]\n";
+ }
+ uscan_verbose $msg;
+ }
+ my ($newversion, $newfile);
+ if (defined $self->shared->{download_version}
+ and not $self->versionmode eq 'ignore') {
+
+ # extract ones which has $match in the above loop defined
+ my @vhrefs = grep { $$_[3] } @hrefs;
+ if (@vhrefs) {
+ (undef, $newversion, $newfile, undef) = @{ $vhrefs[0] };
+ } else {
+ uscan_warn
+"In $self->{watchfile} no matching hrefs for version $self->{shared}->{download_version}"
+ . " in watch line\n $self->{line}";
+ return undef;
+ }
+ } else {
+ if (@hrefs) {
+ (undef, $newversion, $newfile, undef) = @{ $hrefs[0] };
+ } else {
+ uscan_warn
+"In $self->{watchfile} no matching files for watch line\n $self->{line}";
+ return undef;
+ }
+ }
+ return ($newversion, $newfile);
+}
+
+#######################################################################
+# determine $upstream_url (http mode)
+#######################################################################
+# http is complicated due to absolute/relative URL issue
+sub http_upstream_url {
+ my ($self) = @_;
+ my $upstream_url;
+ my $newfile = $self->search_result->{newfile};
+ if ($newfile =~ m%^\w+://%) {
+ $upstream_url = $newfile;
+ } elsif ($newfile =~ m%^//%) {
+ $upstream_url = $self->parse_result->{site};
+ $upstream_url =~ s/^(https?:).*/$1/;
+ $upstream_url .= $newfile;
+ } elsif ($newfile =~ m%^/%) {
+
+ # absolute filename
+ # Were there any redirections? If so try using those first
+ if ($#{ $self->patterns } > 0) {
+
+ # replace $site here with the one we were redirected to
+ foreach my $index (0 .. $#{ $self->patterns }) {
+ if ("$self->{sites}->[$index]$newfile"
+ =~ m&^$self->{patterns}->[$index]$&) {
+ $upstream_url = "$self->{sites}->[$index]$newfile";
+ last;
+ }
+ }
+ if (!defined($upstream_url)) {
+ uscan_verbose
+ "Unable to determine upstream url from redirections,\n"
+ . "defaulting to using site specified in watch file";
+ $upstream_url = "$self->{sites}->[0]$newfile";
+ }
+ } else {
+ $upstream_url = "$self->{sites}->[0]$newfile";
+ }
+ } else {
+ # relative filename, we hope
+ # Were there any redirections? If so try using those first
+ if ($#{ $self->patterns } > 0) {
+
+ # replace $site here with the one we were redirected to
+ foreach my $index (0 .. $#{ $self->patterns }) {
+
+ # skip unless the basedir looks like a directory
+ next unless $self->{basedirs}->[$index] =~ m%/$%;
+ my $nf = "$self->{basedirs}->[$index]$newfile";
+ if ("$self->{sites}->[$index]$nf"
+ =~ m&^$self->{patterns}->[$index]$&) {
+ $upstream_url = "$self->{sites}->[$index]$nf";
+ last;
+ }
+ }
+ if (!defined($upstream_url)) {
+ uscan_verbose
+ "Unable to determine upstream url from redirections,\n"
+ . "defaulting to using site specified in watch file";
+ $upstream_url = "$self->{parse_result}->{urlbase}$newfile";
+ }
+ } else {
+ $upstream_url = "$self->{parse_result}->{urlbase}$newfile";
+ }
+ }
+
+ # mangle if necessary
+ $upstream_url =~ s/&amp;/&/g;
+ uscan_verbose "Matching target for downloadurlmangle: $upstream_url";
+ if (@{ $self->downloadurlmangle }) {
+ if (
+ mangle(
+ $self->watchfile, \$self->line,
+ 'downloadurlmangle:', \@{ $self->downloadurlmangle },
+ \$upstream_url
+ )
+ ) {
+ $self->status(1);
+ return undef;
+ }
+ }
+ return $upstream_url;
+}
+
+sub http_newdir {
+ my ($https, $line, $site, $dir, $pattern, $dirversionmangle,
+ $watchfile, $lineptr, $download_version)
+ = @_;
+
+ my $downloader = $line->downloader;
+ my ($request, $response, $newdir);
+ my ($download_version_short1, $download_version_short2,
+ $download_version_short3)
+ = partial_version($download_version);
+ my $base = $site . $dir;
+
+ $pattern .= "/?";
+
+ if (defined($https) and !$downloader->ssl) {
+ uscan_die
+"$progname: you must have the liblwp-protocol-https-perl package installed\n"
+ . "to use https URLs";
+ }
+ # At least for now, set base in the line object - other methods need it
+ local $line->parse_result->{base} = $base;
+ $request = HTTP::Request->new('GET', $base);
+ $response = $downloader->user_agent->request($request);
+ if (!$response->is_success) {
+ uscan_warn
+ "In watch file $watchfile, reading webpage\n $base failed: "
+ . $response->status_line;
+ return '';
+ }
+
+ my $content = $response->content;
+ if ( $response->header('Content-Encoding')
+ and $response->header('Content-Encoding') =~ /^gzip$/i) {
+ require IO::Uncompress::Gunzip;
+ require IO::String;
+ uscan_debug "content seems gzip encoded, let's decode it";
+ my $out;
+ if (IO::Uncompress::Gunzip::gunzip(IO::String->new($content), \$out)) {
+ $content = $out;
+ } else {
+ uscan_warn 'Unable to decode remote content: '
+ . $IO::Uncompress::GunzipError;
+ return '';
+ }
+ }
+ uscan_extra_debug
+ "received content:\n$content\n[End of received content] by HTTP";
+
+ clean_content(\$content);
+
+ my ($dirpatterns, $base_sites, $base_dirs)
+ = handle_redirection($line, $pattern, $base);
+ $downloader->user_agent->clear_redirections; # we won't be needing that
+
+ my @hrefs;
+ for my $parsed (
+ html_search($line, $content, $dirpatterns, 'dirversionmangle')) {
+ my ($priority, $mangled_version, $href, $match) = @$parsed;
+ $match = '';
+ if (defined $download_version
+ and $mangled_version eq $download_version) {
+ $match = "matched with the download version";
+ }
+ if (defined $download_version_short3
+ and $mangled_version eq $download_version_short3) {
+ $match = "matched with the download version (partial 3)";
+ }
+ if (defined $download_version_short2
+ and $mangled_version eq $download_version_short2) {
+ $match = "matched with the download version (partial 2)";
+ }
+ if (defined $download_version_short1
+ and $mangled_version eq $download_version_short1) {
+ $match = "matched with the download version (partial 1)";
+ }
+ push @hrefs, [$mangled_version, $href, $match];
+ }
+
+ # extract ones which has $match in the above loop defined
+ my @vhrefs = grep { $$_[2] } @hrefs;
+ if (@vhrefs) {
+ @vhrefs = Devscripts::Versort::upstream_versort(@vhrefs);
+ $newdir = $vhrefs[0][1];
+ }
+ if (@hrefs) {
+ @hrefs = Devscripts::Versort::upstream_versort(@hrefs);
+ my $msg = "Found the following matching directories (newest first):\n";
+ foreach my $href (@hrefs) {
+ $msg .= " $$href[1] ($$href[0]) $$href[2]\n";
+ }
+ uscan_verbose $msg;
+ $newdir //= $hrefs[0][1];
+ } else {
+ uscan_warn
+"In $watchfile,\n no matching hrefs for pattern\n $site$dir$pattern";
+ return '';
+ }
+
+ # just give the final directory component
+ $newdir =~ s%/$%%;
+ $newdir =~ s%^.*/%%;
+ return ($newdir);
+}
+
+# Nothing to clean here
+sub http_clean { 0 }
+
+sub clean_content {
+ my ($content) = @_;
+
+ # We need this horrid stuff to handle href=foo type
+ # links. OK, bad HTML, but we have to handle it nonetheless.
+ # It's bug #89749.
+ $$content =~ s/href\s*=\s*(?=[^\"\'])([^\s>]+)/href="$1"/ig;
+
+ # Strip comments
+ $$content =~ s/<!-- .*?-->//sg;
+ return $content;
+}
+
+sub url_canonicalize_dots {
+ my ($base, $url) = @_;
+
+ if ($url !~ m{^[^:#?/]+://}) {
+ if ($url =~ m{^//}) {
+ $base =~ m{^[^:#?/]+:}
+ and $url = $& . $url;
+ } elsif ($url =~ m{^/}) {
+ $base =~ m{^[^:#?/]+://[^/#?]*}
+ and $url = $& . $url;
+ } else {
+ uscan_debug "Resolving urls with query part unimplemented"
+ if ($url =~ m/^[#?]/);
+ $base =~ m{^[^:#?/]+://[^/#?]*(?:/(?:[^#?/]*/)*)?} and do {
+ my $base_to_path = $&;
+ $base_to_path .= '/' unless $base_to_path =~ m|/$|;
+ $url = $base_to_path . $url;
+ };
+ }
+ }
+ $url =~ s{^([^:#?/]+://[^/#?]*)(/[^#?]*)}{
+ my ($h, $p) = ($1, $2);
+ $p =~ s{/\.(?:/|$|(?=[#?]))}{/}g;
+ 1 while $p =~ s{/(?!\.\./)[^/]*/\.\.(?:/|(?=[#?])|$)}{/}g;
+ $h.$p;}e;
+ $url;
+}
+
+sub html_search {
+ my ($self, $content, $patterns, $mangle) = @_;
+
+ # pagenmangle: should not abuse this slow operation
+ if (
+ mangle(
+ $self->watchfile, \$self->line,
+ 'pagemangle:\n', [@{ $self->pagemangle }],
+ \$content
+ )
+ ) {
+ return undef;
+ }
+ if ( !$self->shared->{bare}
+ and $content =~ m%^<[?]xml%i
+ and $content =~ m%xmlns="http://s3.amazonaws.com/doc/2006-03-01/"%
+ and $content !~ m%<Key><a\s+href%) {
+ # this is an S3 bucket listing. Insert an 'a href' tag
+ # into the content for each 'Key', so that it looks like html (LP: #798293)
+ uscan_warn
+"*** Amazon AWS special case code is deprecated***\nUse opts=pagemangle rule, instead";
+ $content =~ s%<Key>([^<]*)</Key>%<Key><a href="$1">$1</a></Key>%g;
+ uscan_extra_debug
+"processed content:\n$content\n[End of processed content] by Amazon AWS special case code";
+ }
+ clean_content(\$content);
+
+ # Is there a base URL given?
+ if ($content =~ /<\s*base\s+[^>]*href\s*=\s*([\"\'])(.*?)\1/i) {
+ $self->parse_result->{urlbase}
+ = url_canonicalize_dots($self->parse_result->{base}, $2);
+ } else {
+ $self->parse_result->{urlbase} = $self->parse_result->{base};
+ }
+ uscan_extra_debug
+"processed content:\n$content\n[End of processed content] by fix bad HTML code";
+
+# search hrefs in web page to obtain a list of uversionmangled version and matching download URL
+ {
+ local $, = ',';
+ uscan_verbose "Matching pattern:\n @{$self->{patterns}}";
+ }
+ my @hrefs;
+ while ($content =~ m/<\s*a\s+[^>]*(?<=\s)href\s*=\s*([\"\'])(.*?)\1/sgi) {
+ my $href = $2;
+ $href = fix_href($href);
+ my $href_canonical
+ = url_canonicalize_dots($self->parse_result->{urlbase}, $href);
+ if (defined $self->hrefdecode) {
+ if ($self->hrefdecode eq 'percent-encoding') {
+ uscan_debug "... Decoding from href: $href";
+ $href =~ s/%([A-Fa-f\d]{2})/chr hex $1/eg;
+ $href_canonical =~ s/%([A-Fa-f\d]{2})/chr hex $1/eg;
+ } else {
+ uscan_warn "Illegal value for hrefdecode: "
+ . "$self->{hrefdecode}";
+ return undef;
+ }
+ }
+ uscan_extra_debug "Checking href $href";
+ foreach my $_pattern (@$patterns) {
+ if (my @match = $href =~ /^$_pattern$/) {
+ push @hrefs,
+ parse_href($self, $href_canonical, $_pattern, \@match,
+ $mangle);
+ }
+ uscan_extra_debug "Checking href $href_canonical";
+ if (my @match = $href_canonical =~ /^$_pattern$/) {
+ push @hrefs,
+ parse_href($self, $href_canonical, $_pattern, \@match,
+ $mangle);
+ }
+ }
+ }
+ return @hrefs;
+}
+
+sub plain_search {
+ my ($self, $content) = @_;
+ my @hrefs;
+ foreach my $_pattern (@{ $self->patterns }) {
+ while ($content =~ s/.*?($_pattern)//) {
+ push @hrefs, $self->parse_href($1, $_pattern, $2);
+ }
+ }
+ $self->parse_result->{urlbase} = $self->parse_result->{base};
+ return @hrefs;
+}
+
+sub parse_href {
+ my ($self, $href, $_pattern, $match, $mangle) = @_;
+ $mangle //= 'uversionmangle';
+
+ my $mangled_version;
+ if ($self->watch_version == 2) {
+
+ # watch_version 2 only recognised one group; the code
+ # below will break version 2 watch files with a construction
+ # such as file-([\d\.]+(-\d+)?) (bug #327258)
+ $mangled_version
+ = ref $match eq 'ARRAY'
+ ? $match->[0]
+ : $match;
+ } else {
+ # need the map { ... } here to handle cases of (...)?
+ # which may match but then return undef values
+ if ($self->versionless) {
+
+ # exception, otherwise $mangled_version = 1
+ $mangled_version = '';
+ } else {
+ $mangled_version = join(".",
+ map { $_ if defined($_) }
+ ref $match eq 'ARRAY' ? @$match : $href =~ m&^$_pattern$&);
+ }
+
+ if (
+ mangle(
+ $self->watchfile, \$self->line,
+ "$mangle:", \@{ $self->$mangle },
+ \$mangled_version
+ )
+ ) {
+ return ();
+ }
+ }
+ $match = '';
+ if (defined $self->shared->{download_version}) {
+ if ($mangled_version eq $self->shared->{download_version}) {
+ $match = "matched with the download version";
+ }
+ }
+ my $priority = $mangled_version . '-' . get_priority($href);
+ return [$priority, $mangled_version, $href, $match];
+}
+
+1;
diff --git a/lib/Devscripts/Uscan/svn.pm b/lib/Devscripts/Uscan/svn.pm
new file mode 100644
index 0000000..65dacae
--- /dev/null
+++ b/lib/Devscripts/Uscan/svn.pm
@@ -0,0 +1,67 @@
+package Devscripts::Uscan::svn;
+
+use strict;
+use Cwd qw/abs_path/;
+use Devscripts::Uscan::Output;
+use Devscripts::Uscan::Utils;
+use Devscripts::Uscan::_vcs;
+use Dpkg::IPC;
+use File::Path 'remove_tree';
+use Moo::Role;
+
+######################################################
+# search $newfile $newversion (svn mode/versionless)
+######################################################
+sub svn_search {
+ my ($self) = @_;
+ my ($newfile, $newversion);
+ if ($self->versionless) {
+ $newfile = $self->parse_result->{base};
+ spawn(
+ exec => [
+ 'svn', 'info',
+ '--show-item', 'last-changed-revision',
+ '--no-newline', $self->parse_result->{base}
+ ],
+ wait_child => 1,
+ to_string => \$newversion
+ );
+ chomp($newversion);
+ $newversion = sprintf '0.0~svn%d', $newversion;
+ if (
+ mangle(
+ $self->watchfile, \$self->line,
+ 'uversionmangle:', \@{ $self->uversionmangle },
+ \$newversion
+ )
+ ) {
+ return undef;
+ }
+
+ }
+ ################################################
+ # search $newfile $newversion (svn mode w/tag)
+ ################################################
+ elsif ($self->mode eq 'svn') {
+ my @args = ('list', $self->parse_result->{base});
+ ($newversion, $newfile)
+ = get_refs($self, ['svn', @args], qr/(.+)/, 'subversion');
+ return undef if !defined $newversion;
+ }
+ return ($newversion, $newfile);
+}
+
+sub svn_upstream_url {
+ my ($self) = @_;
+ my $upstream_url = $self->parse_result->{base};
+ if (!$self->versionless) {
+ $upstream_url .= '/' . $self->search_result->{newfile};
+ }
+ return $upstream_url;
+}
+
+*svn_newfile_base = \&Devscripts::Uscan::_vcs::_vcs_newfile_base;
+
+sub svn_clean { }
+
+1;
diff --git a/lib/Devscripts/Utils.pm b/lib/Devscripts/Utils.pm
new file mode 100644
index 0000000..8495ce4
--- /dev/null
+++ b/lib/Devscripts/Utils.pm
@@ -0,0 +1,31 @@
+package Devscripts::Utils;
+
+use strict;
+use Devscripts::Output;
+use Exporter 'import';
+use IPC::Run qw(run);
+
+our @EXPORT = qw(ds_exec ds_exec_no_fail);
+
+sub ds_exec_no_fail {
+ {
+ local $, = ' ';
+ ds_debug "Execute: @_...";
+ }
+ run \@_, '>', '/dev/null';
+ return $?;
+}
+
+sub ds_exec {
+ {
+ local $, = ' ';
+ ds_debug "Execute: @_...";
+ }
+ run \@_;
+ if ($?) {
+ local $, = ' ';
+ ds_die "Command failed (@_)";
+ }
+}
+
+1;
diff --git a/lib/Devscripts/Versort.pm b/lib/Devscripts/Versort.pm
new file mode 100644
index 0000000..48368d0
--- /dev/null
+++ b/lib/Devscripts/Versort.pm
@@ -0,0 +1,60 @@
+# Copyright (C) 1998,2002 Julian Gilbey <jdg@debian.org>
+#
+# 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 2 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 <https://www.gnu.org/licenses/>.
+
+# The functions in this Perl module are versort, upstream_versort and
+# deb_versort. They each take as input an array of elements of the form
+# [version, data, ...] and sort them into decreasing order according to dpkg's
+# understanding of version sorting. The output is a sorted array. In
+# upstream_versort, "version" is assumed to be an upstream version number only,
+# whereas in deb_versort, "version" is assumed to be a Debian version number,
+# possibly including an epoch and/or a Debian revision. versort is available
+# for compatibility reasons. It compares versions as Debian versions
+# (i.e. 1-2-4 < 1-3) but disables checks for wellformed versions.
+#
+# The returned array has the greatest version as the 0th array element.
+
+package Devscripts::Versort;
+use Dpkg::Version;
+
+sub versort (@) {
+ return _versort(0, sub { return shift->[0] }, @_);
+}
+
+sub deb_versort (@) {
+ return _versort(1, sub { return shift->[0] }, @_);
+}
+
+sub upstream_versort (@) {
+ return _versort(0, sub { return "1:" . shift->[0] . "-0" }, @_);
+}
+
+sub _versort ($@) {
+ my ($check, $getversion, @namever_pairs) = @_;
+
+ foreach my $pair (@namever_pairs) {
+ unshift(@$pair,
+ Dpkg::Version->new(&$getversion($pair), check => $check));
+ }
+
+ my @sorted = sort { $b->[0] <=> $a->[0] } @namever_pairs;
+
+ foreach my $pair (@sorted) {
+ shift @$pair;
+ }
+
+ return @sorted;
+}
+
+1;