diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-15 20:32:59 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-15 20:32:59 +0000 |
commit | 4d57e0a8dab2139a631a21aab862487481548702 (patch) | |
tree | f7cea0b9939e2ecb7a301de6c83bada29452046d /lib/Devscripts/Salsa | |
parent | Initial commit. (diff) | |
download | devscripts-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 '')
34 files changed, 3164 insertions, 0 deletions
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; |