Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package openQA for openSUSE:Factory checked in at 2026-04-20 16:14:25 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/openQA (Old) and /work/SRC/openSUSE:Factory/.openQA.new.11940 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "openQA" Mon Apr 20 16:14:25 2026 rev:839 rq:1348222 version:5.1776681647.73d96c66 Changes: -------- --- /work/SRC/openSUSE:Factory/openQA/openQA.changes 2026-04-15 16:13:39.797426769 +0200 +++ /work/SRC/openSUSE:Factory/.openQA.new.11940/openQA.changes 2026-04-20 16:14:48.521885293 +0200 @@ -1,0 +2,23 @@ +Mon Apr 20 10:40:55 UTC 2026 - [email protected] + +- Update to version 5.1776681647.73d96c66: + * feat(Git): Use nickname as a fallback for fullname + * perf: Select only needed columns in _previous_scenario_jobs + * perf(IssueReporter): Cache regression_links + * feat: ignore "Investigations" job group on dashboard by default + * fix: prevent "Not enough storage" with consistent setting + * refactor: Improve code for `None` auth method + * refactor: Avoid duplicating code for OAuth2 and OpenID auth + * test: Improve OAuth2 tests + * docs: Explain the design of the OAuth2 plugin + * feat: Return to previous page after login via OAuth2 + * fix: ensure priority increases for job groups with NULL description + * feat(Makefile): add convenience targets for all services + * feat: Handle deleted/altered system user more gracefully + * fix(worker): handle missing D-Bus socket gracefully + * test: Consider everything we track coverage of as covered + * test: Cover YAML validation script + * feat(cli): support lower-case test argument passing + * feat(mcp): promote endpoint from /experimental/mcp to /mcp + +------------------------------------------------------------------- Old: ---- openQA-5.1776202410.3448a30a.obscpio New: ---- openQA-5.1776681647.73d96c66.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ openQA-client-test.spec ++++++ --- /var/tmp/diff_new_pack.e2aOG1/_old 2026-04-20 16:14:50.073949246 +0200 +++ /var/tmp/diff_new_pack.e2aOG1/_new 2026-04-20 16:14:50.073949246 +0200 @@ -18,7 +18,7 @@ %define short_name openQA-client Name: %{short_name}-test -Version: 5.1776202410.3448a30a +Version: 5.1776681647.73d96c66 Release: 0 Summary: Test package for %{short_name} License: GPL-2.0-or-later ++++++ openQA-devel-test.spec ++++++ --- /var/tmp/diff_new_pack.e2aOG1/_old 2026-04-20 16:14:50.101950400 +0200 +++ /var/tmp/diff_new_pack.e2aOG1/_new 2026-04-20 16:14:50.105950564 +0200 @@ -18,7 +18,7 @@ %define short_name openQA-devel Name: %{short_name}-test -Version: 5.1776202410.3448a30a +Version: 5.1776681647.73d96c66 Release: 0 Summary: Test package for %{short_name} License: GPL-2.0-or-later ++++++ openQA-test.spec ++++++ --- /var/tmp/diff_new_pack.e2aOG1/_old 2026-04-20 16:14:50.133951718 +0200 +++ /var/tmp/diff_new_pack.e2aOG1/_new 2026-04-20 16:14:50.133951718 +0200 @@ -18,7 +18,7 @@ %define short_name openQA Name: %{short_name}-test -Version: 5.1776202410.3448a30a +Version: 5.1776681647.73d96c66 Release: 0 Summary: Test package for openQA License: GPL-2.0-or-later ++++++ openQA-worker-test.spec ++++++ --- /var/tmp/diff_new_pack.e2aOG1/_old 2026-04-20 16:14:50.165953037 +0200 +++ /var/tmp/diff_new_pack.e2aOG1/_new 2026-04-20 16:14:50.169953202 +0200 @@ -18,7 +18,7 @@ %define short_name openQA-worker Name: %{short_name}-test -Version: 5.1776202410.3448a30a +Version: 5.1776681647.73d96c66 Release: 0 Summary: Test package for %{short_name} License: GPL-2.0-or-later ++++++ openQA.spec ++++++ --- /var/tmp/diff_new_pack.e2aOG1/_old 2026-04-20 16:14:50.197954356 +0200 +++ /var/tmp/diff_new_pack.e2aOG1/_new 2026-04-20 16:14:50.201954520 +0200 @@ -99,7 +99,7 @@ %define devel_requires %devel_no_selenium_requires chromedriver Name: openQA -Version: 5.1776202410.3448a30a +Version: 5.1776681647.73d96c66 Release: 0 Summary: The openQA web-frontend, scheduler and tools License: GPL-2.0-or-later ++++++ openQA-5.1776202410.3448a30a.obscpio -> openQA-5.1776681647.73d96c66.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/Makefile new/openQA-5.1776681647.73d96c66/Makefile --- old/openQA-5.1776202410.3448a30a/Makefile 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/Makefile 2026-04-20 12:40:47.000000000 +0200 @@ -289,7 +289,7 @@ setup-database: ## Set up the test database test -d $(TEST_PG_PATH) && (pg_ctl -D $(TEST_PG_PATH) -s status >&/dev/null || pg_ctl -D $(TEST_PG_PATH) -s start) || ./t/test_postgresql $(TEST_PG_PATH) -define RUN_SERVICE_TEST_DB +define RUN_SERVICE_TEST_ENV OPENQA_BASEDIR=t/data \ OPENQA_DATABASE=test \ OPENQA_WEBUI_MODE=test \ @@ -297,13 +297,25 @@ $(1) endef -.PHONY: run-webui-test-db -run-webui-test-db: setup-database ## Run a local web UI instance using a test database - $(call RUN_SERVICE_TEST_DB,script/openqa-webui-daemon) - -.PHONY: run-gru-test-db -run-gru-test-db: setup-database ## Run a local GRU instance using a test database - $(call RUN_SERVICE_TEST_DB,script/openqa-gru) +.PHONY: run-webui-test-env +run-webui-test-env: setup-database ## Run a local web UI instance using a test environment + $(call RUN_SERVICE_TEST_ENV,script/openqa-webui-daemon) + +.PHONY: run-gru-test-env +run-gru-test-env: setup-database ## Run a local GRU instance using a test environment + $(call RUN_SERVICE_TEST_ENV,script/openqa-gru) + +.PHONY: run-websockets-test-env +run-websockets-test-env: setup-database ## Run a local websockets server instance using a test environment + $(call RUN_SERVICE_TEST_ENV,script/openqa-websockets-daemon) + +.PHONY: run-scheduler-test-env +run-scheduler-test-env: setup-database ## Run a local scheduler instance using a test environment + $(call RUN_SERVICE_TEST_ENV,script/openqa-scheduler-daemon) + +.PHONY: run-worker-test-env +run-worker-test-env: setup-database ## Run a local worker instance using a test environment + $(call RUN_SERVICE_TEST_ENV,script/worker) # prepares running the tests within a container (eg. pulls os-autoinst) and then runs the tests considering # the test matrix environment variables diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/codecov.yml new/openQA-5.1776681647.73d96c66/codecov.yml --- old/openQA-5.1776202410.3448a30a/codecov.yml 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/codecov.yml 2026-04-20 12:40:47.000000000 +0200 @@ -19,10 +19,8 @@ target: 100.0 threshold: 0 paths: - - lib/OpenQA/ - - script/create_admin - - script/initdb - - script/upgradedb + - lib/ + - script/ tests: target: 100.0 threshold: 0 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/docs/WritingTests.asciidoc new/openQA-5.1776681647.73d96c66/docs/WritingTests.asciidoc --- old/openQA-5.1776202410.3448a30a/docs/WritingTests.asciidoc 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/docs/WritingTests.asciidoc 2026-04-20 12:40:47.000000000 +0200 @@ -1934,7 +1934,7 @@ ---- Once enabled the experimental MCP endpoint becomes available under the path -`/experimental/mcp`. At the moment all implemented MCP tools are read-only, +`/mcp`. At the moment all implemented MCP tools are read-only, that means LLMs can review information provided by openQA, but are unable to make changes or trigger actions. Once write operations become available with future updates, they will have to be enabled with a different setting @@ -1953,7 +1953,7 @@ { "mcpServers": { "openqa": { - "url": "http://127.0.0.1:9526/experimental/mcp", + "url": "http://127.0.0.1:9526/mcp", "headers": { "Authorization": "Bearer USER:KEY:SECRET" } @@ -1971,7 +1971,7 @@ [source,shell] ---- -gemini mcp add openqa http://127.0.0.1:9526/experimental/mcp -H 'Authorization: Bearer USER:KEY:SECRET' -t http +gemini mcp add openqa http://127.0.0.1:9526/mcp -H 'Authorization: Bearer USER:KEY:SECRET' -t http ---- After restarting gemini-cli, it will automatically discover available openQA diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/etc/openqa/openqa.ini new/openQA-5.1776681647.73d96c66/etc/openqa/openqa.ini --- old/openQA-5.1776202410.3448a30a/etc/openqa/openqa.ini 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/etc/openqa/openqa.ini 2026-04-20 12:40:47.000000000 +0200 @@ -43,7 +43,7 @@ ## Set to read-only to enable MCP support ## * A Model Context Protocol endpoint will be available to all users under -## /experimental/mcp route. +## /mcp route. ## * Do not enable this feature for openQA instances with special security requirements! #mcp_enabled = no @@ -126,7 +126,7 @@ ## can cause jobs to incomplete with "timestamp mismatch" error messages. #api_hmac_time_tolerance = 300 ## space-separated list of job groups to ignore on the dashboard -#ignored_job_groups = +#ignored_job_groups = Investigations ## How many builds to show per job group on the web UI front page ## Can be overridden with the limit_builds query param #frontpage_builds = 3 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/lib/OpenQA/Git.pm new/openQA-5.1776681647.73d96c66/lib/OpenQA/Git.pm --- old/openQA-5.1776202410.3448a30a/lib/OpenQA/Git.pm 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/lib/OpenQA/Git.pm 2026-04-20 12:40:47.000000000 +0200 @@ -97,6 +97,10 @@ return undef; } +sub _git_author ($self) { + return sprintf '--author=%s <%s>', ($self->user->fullname || $self->user->nickname), $self->user->email; +} + sub commit ($self, $args = undef) { $self->_validate_attributes; @@ -111,9 +115,9 @@ # commit changes my $message = $args->{message}; - my $author = sprintf '--author=%s <%s>', $self->user->fullname, $self->user->email; try { - $self->_run_cmd(['commit', '-q', '-m', $message, $author, @files], {croak => 'Unable to commit via Git'}); + $self->_run_cmd(['commit', '-q', '-m', $message, $self->_git_author, @files], + {croak => 'Unable to commit via Git'}); } catch ($e) { $self->_run_cmd(['restore', '--staged', @files], {force => 1, croak => 'Unable to restore'}) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/lib/OpenQA/Schema/Result/Jobs.pm new/openQA-5.1776681647.73d96c66/lib/OpenQA/Schema/Result/Jobs.pm --- old/openQA-5.1776202410.3448a30a/lib/OpenQA/Schema/Result/Jobs.pm 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/lib/OpenQA/Schema/Result/Jobs.pm 2026-04-20 12:40:47.000000000 +0200 @@ -1660,7 +1660,7 @@ } # return the last X complete jobs of the same scenario -sub _previous_scenario_jobs ($self, $rows = undef) { +sub _previous_scenario_jobs ($self, $rows = undef, $search_attrs = {}) { my $schema = $self->result_source->schema; my $conds = [{'me.state' => 'done'}, {'me.result' => [COMPLETE_RESULTS]}, {'me.id' => {'<', $self->id}}]; for my $key (SCENARIO_WITH_MACHINE_KEYS) { @@ -1668,7 +1668,8 @@ } my %attrs = ( order_by => ['me.id DESC'], - rows => $rows + rows => $rows, + %$search_attrs, ); return $schema->resultset('Jobs')->search({-and => $conds}, \%attrs)->all; } @@ -2046,7 +2047,8 @@ return 0 unless looks_like_number $retry; my $ancestors = $self->ancestors; return 0 if $ancestors >= $retry; - my $system_user_id = $self->result_source->schema->resultset('Users')->system->id; + return 0 unless my $system_user = $self->result_source->schema->resultset('Users')->system({select => ['id']}); + my $system_user_id = $system_user->id; my $msg = "Restarting because RETRY is set to $retry (and only restarted $ancestors times so far)"; $self->comments->create({text => $msg, user_id => $system_user_id}); return 1; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/lib/OpenQA/Schema/ResultSet/Jobs.pm new/openQA-5.1776681647.73d96c66/lib/OpenQA/Schema/ResultSet/Jobs.pm --- old/openQA-5.1776202410.3448a30a/lib/OpenQA/Schema/ResultSet/Jobs.pm 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/lib/OpenQA/Schema/ResultSet/Jobs.pm 2026-04-20 12:40:47.000000000 +0200 @@ -216,8 +216,8 @@ if ($config && $group && (my $group_throttling = $config->{misc_limits}->{prio_group_data})) { for my $rule (@$group_throttling) { my $prop = $rule->{property}; - my $val = $group->$prop; - if (defined $val && $val =~ $rule->{regex}) { + my $val = $group->$prop // ''; + if ($val =~ $rule->{regex}) { $new_job_args->{priority} += $rule->{increment}; my $sign = $rule->{increment} >= 0 ? '+' : ''; push @throttling_info, "$sign$rule->{increment} because job group $prop matches $rule->{regex}"; @@ -558,7 +558,7 @@ return undef if !$job || ($referer->path_query =~ /^\/?$/); my $comments = $job->comments; return undef if $comments->search({text => {like => 'label:linked%'}}, {rows => 1})->first; - my $user = $self->result_source->schema->resultset('Users')->system({select => ['id']}); + return undef unless my $user = $self->result_source->schema->resultset('Users')->system({select => ['id']}); my $bugref = href_to_bugref($referer_url); my $label = $referer_url eq $bugref ? "label:linked $referer" : "label:linked:$bugref"; $comments->create_with_event({text => "$label mentions this job", user_id => $user->id}); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/lib/OpenQA/Schema/ResultSet/Users.pm new/openQA-5.1776681647.73d96c66/lib/OpenQA/Schema/ResultSet/Users.pm --- old/openQA-5.1776202410.3448a30a/lib/OpenQA/Schema/ResultSet/Users.pm 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/lib/OpenQA/Schema/ResultSet/Users.pm 2026-04-20 12:40:47.000000000 +0200 @@ -4,6 +4,10 @@ package OpenQA::Schema::ResultSet::Users; use Mojo::Base 'DBIx::Class::ResultSet', -signatures; +use OpenQA::Log qw(log_error); + +use constant SYSTEM_USER_ERROR => +'Unable to find the "system" user (username: "system", provider: "") so automatic commenting and retrying does not work.'; sub create_user ($self, $id, %attrs) { return unless $id; @@ -28,6 +32,9 @@ return $user; } -sub system ($self, $attrs = undef) { $self->find({username => 'system', provider => ''}, $attrs) } +sub system ($self, $attrs = undef) { + log_error SYSTEM_USER_ERROR unless my $user = $self->find({username => 'system', provider => ''}, $attrs); + return $user; +} 1; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/lib/OpenQA/Setup.pm new/openQA-5.1776681647.73d96c66/lib/OpenQA/Setup.pm --- old/openQA-5.1776202410.3448a30a/lib/OpenQA/Setup.pm 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/lib/OpenQA/Setup.pm 2026-04-20 12:40:47.000000000 +0200 @@ -129,7 +129,7 @@ frontpage_builds => 3, frontpage_time_limit_days => 14, scenario_definitions_allowed_hosts => 'github.com raw.githubusercontent.com', - ignored_job_groups => '', + ignored_job_groups => 'Investigations', }, rate_limits => { search => 5, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/lib/OpenQA/Shared/Controller/Session.pm new/openQA-5.1776681647.73d96c66/lib/OpenQA/Shared/Controller/Session.pm --- old/openQA-5.1776202410.3448a30a/lib/OpenQA/Shared/Controller/Session.pm 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/lib/OpenQA/Shared/Controller/Session.pm 2026-04-20 12:40:47.000000000 +0200 @@ -61,6 +61,8 @@ return $self->redirect_to($ref); } +sub return_page ($self) { $self->param('return_page') || $self->req->headers->referrer } + sub create ($self) { my $ref = $self->req->headers->referrer; my $config = $self->app->config; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/lib/OpenQA/Shared/Plugin/SharedHelpers.pm new/openQA-5.1776681647.73d96c66/lib/OpenQA/Shared/Plugin/SharedHelpers.pm --- old/openQA-5.1776202410.3448a30a/lib/OpenQA/Shared/Plugin/SharedHelpers.pm 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/lib/OpenQA/Shared/Plugin/SharedHelpers.pm 2026-04-20 12:40:47.000000000 +0200 @@ -51,16 +51,13 @@ # If the value is not in the stash my $current_user = $c->stash('current_user'); unless ($current_user && ($current_user->{no_user} || defined $current_user->{user})) { - my $auth_method = $c->app->config->{auth}->{method} // ''; - my $id = $c->session->{user} // ($auth_method eq 'None' ? 'admin' : undef); - my $user = $id ? $c->schema->resultset('Users')->find({username => $id}) : undef; - if (!$user && $auth_method eq 'None') { - $user = $c->schema->resultset('Users') - ->create_user('admin', fullname => 'Administrator', email => '[email protected]'); - $user->update({is_admin => 1, is_operator => 1}); - } - if ($user && $auth_method eq 'None' && !($user->is_admin && $user->is_operator)) { - $user->update({is_admin => 1, is_operator => 1}); + my $is_auth_method_none = ($c->app->config->{auth}->{method} // '') eq 'None'; + my $id = $c->session->{user} // ($is_auth_method_none ? 'admin' : undef); + my $users = $c->schema->resultset('Users'); + my $user = $id ? $users->find({username => $id}) : undef; + if ($is_auth_method_none) { + $user ||= $users->create_user('admin', fullname => 'Administrator', email => '[email protected]'); + $user->update({is_admin => 1, is_operator => 1}) unless $user->is_admin && $user->is_operator; } $c->stash(current_user => $current_user = $user ? {user => $user} : {no_user => 1}); } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/lib/OpenQA/WebAPI/Auth/OAuth2.pm new/openQA-5.1776681647.73d96c66/lib/OpenQA/WebAPI/Auth/OAuth2.pm --- old/openQA-5.1776202410.3448a30a/lib/OpenQA/WebAPI/Auth/OAuth2.pm 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/lib/OpenQA/WebAPI/Auth/OAuth2.pm 2026-04-20 12:40:47.000000000 +0200 @@ -8,6 +8,12 @@ use Mojo::Util qw(dumper); use OpenQA::Log qw(log_debug); +# The OAuth2 provider handles login requests and responses from the auth provider both via the `/login` route. +# So both cases are handled in `auth_login` and `auth_response` is not implemented. This is in-line with the +# helper `get_token_p` from `Mojolicious::Plugin::OAuth2` which is also designed to do these two things at the +# same time (see https://metacpan.org/pod/Mojolicious::Plugin::OAuth2#oauth2.get_token_p). The distinction +# between the cases is made by whether `$data` is passed to `update_user`. + sub auth_setup ($server) { my $app = $server->app; my $config = $app->config->{oauth2}; @@ -85,18 +91,26 @@ fullname => $details->{name}, email => $details->{email}); - $controller->session->{user} = $user->username; - $controller->redirect_to('index'); + my $session = $controller->session; + $session->{user} = $user->username; + $controller->redirect_to(Mojo::URL->new($session->{return_page} // 'index')->path_query); } +sub auth_logout ($controller) { delete $controller->session->{return_page} } + sub auth_login ($controller) { croak 'Config was not parsed' unless my $main_config = $controller->app->config->{oauth2}; croak 'Setup was not called' unless my $provider_config = $main_config->{provider_config}; + my $handle_login_response_from_provider = defined $controller->param('code'); + my $handle_login_request = !$handle_login_response_from_provider; my $base_url = $controller->app->config->{global}->{base_url}; my $host = $base_url ? Mojo::URL->new($base_url)->host : $controller->req->url->host; my $get_token_args = {redirect_uri => $controller->url_for('login')->userinfo(undef)->host($host)->to_abs}; $get_token_args->{scope} = $provider_config->{token_scope}; + if (my $return_page = $handle_login_request ? $controller->return_page : undef) { + $controller->session->{return_page} = $return_page; + } $controller->oauth2->get_token_p($main_config->{provider} => $get_token_args) ->then(sub { update_user($controller, $main_config, $provider_config, shift) }) ->catch(sub { $controller->render(text => shift, status => 403) }); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/lib/OpenQA/WebAPI/Auth/OpenID.pm new/openQA-5.1776681647.73d96c66/lib/OpenQA/WebAPI/Auth/OpenID.pm --- old/openQA-5.1776202410.3448a30a/lib/OpenQA/WebAPI/Auth/OpenID.pm 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/lib/OpenQA/WebAPI/Auth/OpenID.pm 2026-04-20 12:40:47.000000000 +0200 @@ -47,7 +47,7 @@ ); my $return_url = Mojo::URL->new(qq{$url/response}); - if (my $return_page = $c->param('return_page') || $c->req->headers->referrer) { + if (my $return_page = $c->return_page) { $return_page = Mojo::URL->new($return_page)->path_query; # return_page is encoded using base64 (in a version that avoids / and + symbol) # as any special characters like / or ? when urlencoded via % symbols, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/lib/OpenQA/WebAPI/Controller/API/V1/Iso.pm new/openQA-5.1776681647.73d96c66/lib/OpenQA/WebAPI/Controller/API/V1/Iso.pm --- old/openQA-5.1776202410.3448a30a/lib/OpenQA/WebAPI/Controller/API/V1/Iso.pm 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/lib/OpenQA/WebAPI/Controller/API/V1/Iso.pm 2026-04-20 12:40:47.000000000 +0200 @@ -10,6 +10,7 @@ use OpenQA::Schema::Result::JobDependencies; use constant MANDATORY_PARAMETERS => qw(DISTRI VERSION FLAVOR ARCH); +use constant RESERVED_API_KEYS_RE => qr/^(?:async|scheduled_product_clone_id)$/; =pod @@ -157,7 +158,9 @@ =cut sub create ($self) { - my $params = $self->req->params->to_hash; + my $raw_params = $self->req->params->to_hash; + my $params = {map { ($_ !~ RESERVED_API_KEYS_RE ? uc($_) : $_) => $raw_params->{$_} } keys %$raw_params}; + $self->validation->input({%$params}); my $async = delete $params->{async}; # whether to run the operation as a Minion job my $scheduled_product_clone_id = delete $params->{scheduled_product_clone_id}; # ID of a previous product to clone settings from @@ -202,8 +205,8 @@ } # add user-specified $params to %params for the new scheduled product and validate download parameters - # note: keys are converted to upper-case, URL-encoded slashes are restored - $params{uc $_} = ($params->{$_} =~ s@%2F@/@gr) for keys %$params; + # note: URL-encoded slashes are restored + $params{$_} = ($params->{$_} =~ s@%2F@/@gr) for keys %$params; return undef unless $self->validate_download_parameters(\%params); # add entry to ScheduledProducts table and log event diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/lib/OpenQA/WebAPI/Plugin/IssueReporter/Context.pm new/openQA-5.1776681647.73d96c66/lib/OpenQA/WebAPI/Plugin/IssueReporter/Context.pm --- old/openQA-5.1776202410.3448a30a/lib/OpenQA/WebAPI/Plugin/IssueReporter/Context.pm 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/lib/OpenQA/WebAPI/Plugin/IssueReporter/Context.pm 2026-04-20 12:40:47.000000000 +0200 @@ -30,6 +30,9 @@ # this takes the logic from the external_reporting.html.ep sub get_regression_links ($c, $job) { + if (my $regression_links = $c->stash('regression_links')) { + return @$regression_links; + } my $build_link = sub ($j) { my $turl = $c->url_for('test', testid => $j->id)->to_abs; return '[' . $j->BUILD . "]($turl)"; @@ -38,13 +41,14 @@ my $first_known_bad = $build_link->($job) . ' (current job)'; my $last_good = '(unknown)'; - for my $prev ($job->_previous_scenario_jobs) { + for my $prev ($job->_previous_scenario_jobs(undef, {select => [qw/ me.id me.BUILD me.result /]})) { if (($prev->result // '') =~ /(passed|softfailed)/) { $last_good = $build_link->($prev); last; } $first_known_bad = $build_link->($prev); } + $c->stash(regression_links => [$first_known_bad, $last_good]); return ($first_known_bad, $last_good); } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/lib/OpenQA/WebAPI/Plugin/MCP.pm new/openQA-5.1776681647.73d96c66/lib/OpenQA/WebAPI/Plugin/MCP.pm --- old/openQA-5.1776202410.3448a30a/lib/OpenQA/WebAPI/Plugin/MCP.pm 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/lib/OpenQA/WebAPI/Plugin/MCP.pm 2026-04-20 12:40:47.000000000 +0200 @@ -79,7 +79,7 @@ ); my $mcp_auth = $app->routes->under('/')->to('Auth#auth')->name('mcp_ensure_user'); - $mcp_auth->any('/experimental/mcp' => $mcp->to_action)->name('mcp'); + $mcp_auth->any('/mcp' => $mcp->to_action)->name('mcp'); } sub tool_openqa_get_info ($tool, $args) { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/lib/OpenQA/Worker.pm new/openQA-5.1776681647.73d96c66/lib/OpenQA/Worker.pm --- old/openQA-5.1776202410.3448a30a/lib/OpenQA/Worker.pm 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/lib/OpenQA/Worker.pm 2026-04-20 12:40:47.000000000 +0200 @@ -643,7 +643,10 @@ try { defined &Net::DBus::system or require Net::DBus } catch ($e) { return 0 } # uncoverable statement unless (defined $self->{_system_dbus}) { - $self->{_system_dbus} = Net::DBus->system(nomainloop => 1); + my $dbus; + try { $dbus = Net::DBus->system(nomainloop => 1) } + catch ($e) { return 0 } # uncoverable statement + $self->{_system_dbus} = $dbus; # this avoids piling up signals we never do anything with - POO #183833 $self->{_system_dbus}->get_bus_object->disconnect_from_signal('NameOwnerChanged', 1); } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/t/03-auth-openid.t new/openQA-5.1776681647.73d96c66/t/03-auth-openid.t --- old/openQA-5.1776202410.3448a30a/t/03-auth-openid.t 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/t/03-auth-openid.t 2026-04-20 12:40:47.000000000 +0200 @@ -36,7 +36,8 @@ my $req = Test::MockObject->new->set_always(params => $params)->set_always(url => $url); my $log = Test::MockObject->new->set_true('error'); my $app = Test::MockObject->new->set_always(config => {})->set_always(log => $log); -$c->set_always(req => $req)->set_always(app => $app)->set_true('flash'); +$c->set_always(return_page => undef)->set_always(req => $req)->set_always(app => $app)->set_true('flash'); +Test::MockObject->new->set_always(params => $params)->set_always(url => $url); is +OpenQA::WebAPI::Auth::OpenID::auth_response($c), 0, 'can call auth_response'; $c->app->log->called_ok('error', 'an error was logged for call without proper config'); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/t/03-auth.t new/openQA-5.1776681647.73d96c66/t/03-auth.t --- old/openQA-5.1776202410.3448a30a/t/03-auth.t 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/t/03-auth.t 2026-04-20 12:40:47.000000000 +0200 @@ -40,7 +40,7 @@ my $t = Test::Mojo->new('OpenQA::WebAPI'); $t->app->helper(icon_url => sub { '/favicon.ico' }); is $t->app->config->{auth}->{method}, $auth, "started successfully with auth $auth"; - $t->get_ok('/login' => {Referer => 'http://open.qa/tests/42'}); + $t->get_ok('/login' => {Referer => 'http://open.qa/tests/42'})->status_is(302, 'got redirected'); } sub mojo_has_request_debug { $Mojolicious::VERSION <= 9.21 } @@ -152,19 +152,28 @@ }; subtest OAuth2 => sub { - my $t = Test::Mojo->new('OpenQA::WebAPI'); - lives_ok { $t->app->plugin(OAuth2 => {mocked => {key => 'deadbeef'}}) } 'auth mocked'; - throws_ok { test_auth_method_startup 'OAuth2' } qr/No OAuth2 provider selected/, 'Error with no provider selected'; - throws_ok { test_auth_method_startup('OAuth2', ("[oauth2]\n", "provider = foo\n")) } - qr/OAuth2 provider 'foo' not supported/, 'Error with unsupported provider'; - combined_like { test_auth_method_startup('OAuth2', ("[oauth2]\n", "provider = github\n")) } - mojo_has_request_debug ? qr/302 Found/ : qr//, 'Plugin loaded'; - my $ua_mock = Test::MockModule->new('Mojo::UserAgent'); my $msg_mock = Test::MockModule->new('Mojo::Message'); - my @get_args; my $get_tx = Mojo::Transaction->new; - $ua_mock->redefine(get => sub { shift; push @get_args, [@_]; $get_tx }); + my @get_args; + $ua_mock->redefine(get => sub ($ua, @args) { push @get_args, [@args]; $get_tx }); + + my $t = Test::Mojo->new('OpenQA::WebAPI'); + lives_ok { $t->app->plugin(OAuth2 => {mocked => {key => 'deadbeef'}}) } 'auth mocked'; + + subtest 'auth_login function via /login route' => sub { + throws_ok { test_auth_method_startup 'OAuth2' } qr/No OAuth2 provider selected/, + 'Error with no provider selected'; + throws_ok { test_auth_method_startup('OAuth2', ("[oauth2]\n", "provider = foo\n")) } + qr/OAuth2 provider 'foo' not supported/, 'Error with unsupported provider'; + $msg_mock->redefine(json => {id => 42, login => 'Demo'}); + combined_like { + my $t = test_auth_method_startup('OAuth2', ("[oauth2]\n", "provider = github\n")); + like $t->tx->res->headers->header('Location'), qr/github\.com/, 'redirection to GitHub'; + $t->get_ok('/login?code=foo')->status_is(403, 'login with wrong code prevented'); + } + mojo_has_request_debug ? qr/302 Found/ : qr//, 'Plugin loaded'; + }; my %main_cfg = (provider => 'custom'); my %provider_cfg @@ -176,6 +185,7 @@ subtest 'failure when requesting user details' => sub { my $c = $t->app->build_controller; $get_tx->res->error({code => 500, message => 'Internal server error'}); + $msg_mock->unmock('json'); OpenQA::WebAPI::Auth::OAuth2::update_user($c, \%main_cfg, \%provider_cfg, \%data); is $c->res->code, 403, 'status code'; is $c->res->body, '500 response: Internal server error', 'error message'; @@ -197,10 +207,18 @@ my $c = $t->app->build_controller; $get_tx->res->error(undef); $msg_mock->redefine(json => {id => 42, login => 'Demo'}); + $t->app->helper(return_page => sub ($c) { 'http://test/foo/bar' }); + $t->app->config->{oauth2} = {provider_config => \%provider_cfg}; + throws_ok { OpenQA::WebAPI::Auth::OAuth2::auth_login($c) } qr/invalid provider/i, + 'auth login executed as far as needed to assign return page'; + is $c->session->{return_page}, 'http://test/foo/bar', 'page to return to saved via session'; OpenQA::WebAPI::Auth::OAuth2::update_user($c, \%main_cfg, \%provider_cfg, \%data); is $c->res->code, 302, 'status code (redirection)'; + is $c->res->headers->header('Location'), '/foo/bar', 'redirection to previous page (only path/query)'; is $c->session->{user}, '42', 'user set'; is $users->search(\%expected_user)->count, 1, 'user created'; + OpenQA::WebAPI::Auth::OAuth2::auth_logout($c); + ok !exists $c->session->{return_page}, 'return page cleared on logout'; }; }; @@ -208,3 +226,5 @@ 'refused to start with non existent auth module'; done_testing; + +END { path("$FindBin::Bin/data/openqa/share/factory/tmp")->remove_tree } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/t/06-users.t new/openQA-5.1776681647.73d96c66/t/06-users.t --- old/openQA-5.1776202410.3448a30a/t/06-users.t 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/t/06-users.t 2026-04-20 12:40:47.000000000 +0200 @@ -11,10 +11,12 @@ use OpenQA::Test::TimeLimit '10'; use Test::Mojo; use Test::Warnings ':report_warnings'; +use Test::Output 'combined_like'; OpenQA::Test::Database->new->create; my $t = Test::Mojo->new('OpenQA::WebAPI'); -my $users = $t->app->schema->resultset('Users'); +my $schema = $t->app->schema; +my $users = $schema->resultset('Users'); subtest 'new users are not ops and admins' => sub { my $mordred_id = 'https://openid.badguys.uk/mordred'; @@ -29,6 +31,13 @@ ok !$system_user->is_admin, 'system user is not an admin'; ok !$system_user->is_operator, 'system user is not an operator'; is $system_user->email, '[email protected]', 'system user`s email uses open.qa domain'; + subtest 'deleted system user handled' => sub { + $schema->txn_begin; + $users->search({username => 'system', provider => ''})->delete; + combined_like { is $users->system, undef, 'system returns undef if user has been deleted' } + qr/automatic commenting.*does not work/, 'warning logged if system user is missing'; + $schema->txn_rollback; + }; }; subtest 'new user is admin if no admin is present' => sub { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/t/14-grutasks-git.t new/openQA-5.1776681647.73d96c66/t/14-grutasks-git.t --- old/openQA-5.1776202410.3448a30a/t/14-grutasks-git.t 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/t/14-grutasks-git.t 2026-04-20 12:40:47.000000000 +0200 @@ -416,6 +416,14 @@ $t->app->log(Mojo::Log->new(level => 'info')); +subtest 'commit author' => sub { + my $user = Test::MockObject->new; + $user->mock(email => sub { '[email protected]' })->mock(nickname => sub { 'nobody' }) + ->mock(fullname => undef); + my $git = OpenQA::Git->new(app => $t->app, user => $user); + is $git->_git_author, '--author=nobody <[email protected]>'; +}; + subtest 'delete_needles' => sub { my $needledirs = $schema->resultset('NeedleDirs'); my $needles = $schema->resultset('Needles'); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/t/43-cli-schedule.t new/openQA-5.1776681647.73d96c66/t/43-cli-schedule.t --- old/openQA-5.1776202410.3448a30a/t/43-cli-schedule.t 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/t/43-cli-schedule.t 2026-04-20 12:40:47.000000000 +0200 @@ -12,6 +12,7 @@ use OpenQA::CLI; use OpenQA::CLI::monitor; use OpenQA::CLI::schedule; +use OpenQA::CLI::api; use OpenQA::Jobs::Constants; use OpenQA::Test::Case; use OpenQA::Test::Utils qw(perform_minion_jobs); @@ -28,7 +29,7 @@ # change API to simulate job state/result changes my $job_controller_mock = Test::MockModule->new('OpenQA::WebAPI::Controller::API::V1::Job'); -my @job_mock_results = (PASSED, SOFTFAILED, PASSED, USER_CANCELLED, PASSED, SOFTFAILED); +my @job_mock_results = (PASSED, SOFTFAILED, PASSED, USER_CANCELLED, PASSED, SOFTFAILED, PASSED, SOFTFAILED); $job_controller_mock->redefine( get_status => sub ($self) { # reply as usual @@ -72,6 +73,7 @@ my @settings1 = (qw(DISTRI=example VERSION=0 FLAVOR=DVD ARCH=x86_64 TEST=simple_boot)); my @settings2 = (qw(DISTRI=opensuse VERSION=13.1 FLAVOR=DVD ARCH=i586 BUILD=0091 TEST=autoyast_btrfs)); my @settings3 = (@settings2, qw(async=1)); +my @settings4 = (qw(distri=opensuse version=13.1 flavor=DVD arch=i586 build=0091 test=autoyast_btrfs)); subtest 'running into error reply' => sub { my $res; @@ -106,6 +108,11 @@ qr|"successful_job_ids":\[\d+,\d+\].*passed.*softfailed|s, 'response logged if async=1 flag was used'; is $res, 0, 'zero return-code if all jobs are ok with async=1 flag'; + combined_like { $res = $schedule->run(@options, @scenarios, @settings4) } + qr|2 jobs have been created.*(http://127.0.0.1.*/tests/\d+.*){2}passed.*softfailed|s, + 'response logged if jobs created correctly from lower-case arguments'; + is $res, 0, 'zero return-code if all jobs are ok with lower-case arguments'; + $fake_scheduled_product_status = OpenQA::Schema::Result::ScheduledProducts::CANCELLED; combined_like { $res = $schedule->run(@options, @scenarios, @settings3) } qr|Scheduled product \d+ ended up cancelled|s, @@ -132,4 +139,16 @@ is $res, 0, 'zero return-code if clone of followed jobs ok'; }; +subtest 'api command preserves lowercase keys for routes expecting them' => sub { + my $res; + my $api = OpenQA::CLI::api->new; + my @api_options = ('--apikey', 'ARTHURKEY01', '--apisecret', 'EXCALIBUR', '--host', $host); + combined_like { + $res = $api->run(@api_options, 'isos/job_stats', 'distri=example', 'version=0', 'flavor=DVD', 'arch=x86_64', + 'build=123') + } + qr/\{\}/s, 'response logged successfully because lower-case arguments were preserved'; + is $res, 0, 'zero return-code for successful api call with lower-case arguments'; +}; + done_testing(); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/t/44-scripts.t new/openQA-5.1776681647.73d96c66/t/44-scripts.t --- old/openQA-5.1776202410.3448a30a/t/44-scripts.t 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/t/44-scripts.t 2026-04-20 12:40:47.000000000 +0200 @@ -51,6 +51,20 @@ or diag "Output: $out"; } +subtest 'YAML validation' => sub { + my $script = 'openqa-validate-yaml'; + my $job_templates = "$Bin/data/job-templates"; + my ($success, $out, $is_timeout) = run_script $script, ['--validate-schema', "$job_templates/openqa.yaml"]; + ok $success && !$is_timeout, 'validation passed'; + like $out, qr/Validating schema/, 'schema validated as well'; + like $out, qr/openqa.yaml - valid/, 'YAML considered valid'; + ($success, $out, $is_timeout) = run_script $script, ["$job_templates/openqa-invalid.yaml"]; + ok !$success, 'validation failed'; + like $out, qr/not allowed.*invalid.*failed/s, 'YAML considered invalid'; + $out = qx{cat '$job_templates/openqa-invalid.yaml' | '$Bin/../script/$script' - 2>&1}; + like $out, qr/not allowed.*invalid.*failed/s, 'can read YAML from stdin' or always_explain "$script: $!"; +}; + # cover timeout handling in run_script { my $run_mock = Test::MockModule->new('main', no_auto => 1); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/t/api/04-jobs.t new/openQA-5.1776681647.73d96c66/t/api/04-jobs.t --- old/openQA-5.1776202410.3448a30a/t/api/04-jobs.t 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/t/api/04-jobs.t 2026-04-20 12:40:47.000000000 +0200 @@ -1134,6 +1134,21 @@ $t->app->config->{misc_limits}->{prio_group_data} = undef; }; + subtest 'priority adjustment based on empty job group description' => sub { + my $group = $schema->resultset('JobGroups')->find({name => 'opensuse'}); + $group->update({description => undef}); + my %new_job_args = (priority => $default_prio); + $t->app->config->{misc_limits}->{prio_group_parameters} = 'description:^$:73'; + $t->app->config->{misc_limits}->{prio_group_data} + = OpenQA::Setup::_load_prio_group_throttling($t->app, $t->app->config); + OpenQA::Schema::ResultSet::Jobs::_apply_prio_throttling({}, \%new_job_args, $group); + is $new_job_args{priority}, $default_prio + 73, 'priority increased based on empty group description'; + + # reset for following tests + $t->app->config->{misc_limits}->{prio_group_parameters} = ''; + $t->app->config->{misc_limits}->{prio_group_data} = undef; + }; + subtest 'priority scaled up due to QEMURAM demand' => sub { my %new_job_args = (priority => $default_prio); my $add = 20; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/t/api/17-mcp.t new/openQA-5.1776681647.73d96c66/t/api/17-mcp.t --- old/openQA-5.1776202410.3448a30a/t/api/17-mcp.t 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/t/api/17-mcp.t 2026-04-20 12:40:47.000000000 +0200 @@ -22,13 +22,13 @@ my $PERSONAL_ACCESS_TOKEN = 'lance:LANCELOTKEY01:MANYPEOPLEKNOW'; my $t = Test::Mojo->new('OpenQA::WebAPI'); -my $client = MCP::Client->new(ua => $t->ua, url => $t->ua->server->url->path('/experimental/mcp')); +my $client = MCP::Client->new(ua => $t->ua, url => $t->ua->server->url->path('/mcp')); subtest 'Authentication' => sub { - $t->get_ok('/experimental/mcp')->status_is(403)->json_is({error => 'no api key'}); + $t->get_ok('/mcp')->status_is(403)->json_is({error => 'no api key'}); $t->ua->on(start => sub ($ua, $tx) { $tx->req->headers->authorization("Bearer $PERSONAL_ACCESS_TOKEN") }); - $t->get_ok('/experimental/mcp')->status_is(405); + $t->get_ok('/mcp')->status_is(405); }; subtest 'Start session' => sub { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/openQA-5.1776202410.3448a30a/t/full-stack.t new/openQA-5.1776681647.73d96c66/t/full-stack.t --- old/openQA-5.1776202410.3448a30a/t/full-stack.t 2026-04-14 23:33:30.000000000 +0200 +++ new/openQA-5.1776681647.73d96c66/t/full-stack.t 2026-04-20 12:40:47.000000000 +0200 @@ -17,6 +17,8 @@ # ensure the web socket connection won't timeout $ENV{MOJO_INACTIVITY_TIMEOUT} = 10 * 60; + + $ENV{OS_AUTOINST_STORAGE_KEEP_FREE_RATIO} = 0; } use Test::Warnings ':report_warnings'; ++++++ openQA.obsinfo ++++++ --- /var/tmp/diff_new_pack.e2aOG1/_old 2026-04-20 16:15:03.038483445 +0200 +++ /var/tmp/diff_new_pack.e2aOG1/_new 2026-04-20 16:15:03.042483610 +0200 @@ -1,5 +1,5 @@ name: openQA -version: 5.1776202410.3448a30a -mtime: 1776202410 -commit: 3448a30abe551cd730cfa95a833c3e419ecada98 +version: 5.1776681647.73d96c66 +mtime: 1776681647 +commit: 73d96c66c0792a9fca8248b79c369eeb0fb0a76f
