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-11-09 16:05:06
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/openQA (Old)
 and      /work/SRC/openSUSE:Factory/.openQA.new.3759290 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "openQA"

Mon Nov  9 16:05:06 2026 rev:906 rq:1396212 version:5.1794183862.c3440c51

Changes:
--------
--- /work/SRC/openSUSE:Factory/openQA/openQA.changes    2026-11-07 
23:34:57.614366127 +0100
+++ /work/SRC/openSUSE:Factory/.openQA.new.3759290/openQA.changes       
2026-11-09 16:05:51.100851226 +0100
@@ -1,0 +2,6 @@
+Mon Nov 09 00:24:35 UTC 2026 - [email protected]
+
+- Update to version 5.1794183862.c3440c51:
+  * feat(investigation): List recent commits to files of failed modules
+
+-------------------------------------------------------------------

Old:
----
  openQA-5.1794057672.cbb4e678.obscpio

New:
----
  openQA-5.1794183862.c3440c51.obscpio

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ openQA-client-test.spec ++++++
--- /var/tmp/diff_new_pack.Di3F2J/_old  2026-11-09 16:05:52.778921575 +0100
+++ /var/tmp/diff_new_pack.Di3F2J/_new  2026-11-09 16:05:52.780921659 +0100
@@ -18,7 +18,7 @@
 
 %define         short_name openQA-client
 Name:           %{short_name}-test
-Version:        5.1794057672.cbb4e678
+Version:        5.1794183862.c3440c51
 Release:        0
 Summary:        Test package for %{short_name}
 License:        GPL-2.0-or-later

++++++ openQA-test.spec ++++++
--- /var/tmp/diff_new_pack.Di3F2J/_old  2026-11-09 16:05:52.808922833 +0100
+++ /var/tmp/diff_new_pack.Di3F2J/_new  2026-11-09 16:05:52.809922875 +0100
@@ -18,7 +18,7 @@
 
 %define         short_name openQA
 Name:           %{short_name}-test
-Version:        5.1794057672.cbb4e678
+Version:        5.1794183862.c3440c51
 Release:        0
 Summary:        Test package for openQA
 License:        GPL-2.0-or-later

++++++ openQA-worker-test.spec ++++++
--- /var/tmp/diff_new_pack.Di3F2J/_old  2026-11-09 16:05:52.837924048 +0100
+++ /var/tmp/diff_new_pack.Di3F2J/_new  2026-11-09 16:05:52.839924132 +0100
@@ -18,7 +18,7 @@
 
 %define         short_name openQA-worker
 Name:           %{short_name}-test
-Version:        5.1794057672.cbb4e678
+Version:        5.1794183862.c3440c51
 Release:        0
 Summary:        Test package for %{short_name}
 License:        GPL-2.0-or-later

++++++ openQA.spec ++++++
--- /var/tmp/diff_new_pack.Di3F2J/_old  2026-11-09 16:05:52.871925474 +0100
+++ /var/tmp/diff_new_pack.Di3F2J/_new  2026-11-09 16:05:52.872925516 +0100
@@ -104,7 +104,7 @@
 %define devel_requires %devel_no_selenium_requires chromedriver
 
 Name:           openQA
-Version:        5.1794057672.cbb4e678
+Version:        5.1794183862.c3440c51
 Release:        0
 Summary:        Framework for automated system-level testing (web-frontend, 
scheduler and tools)
 Group:          Development/Tools/Other

++++++ openQA-5.1794057672.cbb4e678.obscpio -> 
openQA-5.1794183862.c3440c51.obscpio ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/openQA-5.1794057672.cbb4e678/assets/javascripts/test_result.js 
new/openQA-5.1794183862.c3440c51/assets/javascripts/test_result.js
--- old/openQA-5.1794057672.cbb4e678/assets/javascripts/test_result.js  
2026-11-07 14:21:12.000000000 +0100
+++ new/openQA-5.1794183862.c3440c51/assets/javascripts/test_result.js  
2026-11-09 01:24:22.000000000 +0100
@@ -983,6 +983,38 @@
   return commits;
 }
 
+// "(#<number>)" is only known to mean a pull request on GitHub and 
Gitea/Forgejo hosts
+const PULL_REQUEST_PATHS = [
+  [/^github\.com$/, 'pull/'],
+  [/^(?:src\.opensuse\.org|codeberg\.org)$|gitea|forgejo/, 'pulls/']
+];
+
+function pullRequestUrl(commitUrl, number) {
+  let url;
+  try {
+    url = new URL(commitUrl);
+  } catch {
+    return null;
+  }
+  const path = PULL_REQUEST_PATHS.find(([host]) => host.test(url.hostname));
+  return path && commitUrl.endsWith('/commit/') ? 
commitUrl.replace(/commit\/$/, path[1]) + number : null;
+}
+
+function createCommitMessageLink(msg, repoUrl) {
+  const fragment = document.createDocumentFragment();
+  const match = msg.match(/^(.*) \(#(\d+)\)$/);
+  const href = match && pullRequestUrl(repoUrl, match[2]);
+  if (!href) {
+    fragment.append(msg);
+    return fragment;
+  }
+  const link = document.createElement('a');
+  link.href = href;
+  link.textContent = '#' + match[2];
+  fragment.append(match[1] + ' (', link, ')');
+  return fragment;
+}
+
 function setupTestDetailsFilter(tabConfig) {
   if (tabConfig._hasFilterHandlers) return;
 
@@ -1206,8 +1238,9 @@
   const needlegiturl = response.needlegiturl;
   delete response.testgiturl;
   delete response.needlegiturl;
-  document.getElementById('investigation').setAttribute('data-testgiturl', 
testgiturl);
-  document.getElementById('investigation').setAttribute('data-needlegiturl', 
needlegiturl);
+  const investigation = document.getElementById('investigation');
+  if (testgiturl) investigation.setAttribute('data-testgiturl', testgiturl);
+  if (needlegiturl) investigation.setAttribute('data-needlegiturl', 
needlegiturl);
 
   const theadElement = document.createElement('thead');
   const headTrElement = document.createElement('tr');
@@ -1281,7 +1314,8 @@
             const logDetailsDiv = document.createElement('div');
             logDetailsDiv.id = 'collapse' + key + i;
             logDetailsDiv.className = 'collapse';
-            spanElem.innerHTML = gitstats[i].link + ' ' + gitstats[i].msg;
+            spanElem.innerHTML = gitstats[i].link + ' ';
+            spanElem.appendChild(createCommitMessageLink(gitstats[i].msg, 
repoUrl));
             logDetailsDiv.innerHTML = gitstats[i].stat.join('\n');
             statItem.append(collapseSign, spanElem, logDetailsDiv);
 
@@ -1350,7 +1384,12 @@
 }
 
 function getInvestigationDataAttr(key) {
-  const attrs = {test_log: 'data-testgiturl', needles_log: 
'data-needlegiturl'};
+  const attrs = {
+    test_log: 'data-testgiturl',
+    needles_log: 'data-needlegiturl',
+    failure_test_log: 'data-testgiturl',
+    failure_needles_log: 'data-needlegiturl'
+  };
   const investigation = document.getElementById('investigation');
   return investigation ? investigation.getAttribute(attrs[key]) : null;
 }
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/openQA-5.1794057672.cbb4e678/docs/UsersGuide.md 
new/openQA-5.1794183862.c3440c51/docs/UsersGuide.md
--- old/openQA-5.1794057672.cbb4e678/docs/UsersGuide.md 2026-11-07 
14:21:12.000000000 +0100
+++ new/openQA-5.1794183862.c3440c51/docs/UsersGuide.md 2026-11-09 
01:24:22.000000000 +0100
@@ -720,6 +720,32 @@
 autologin-timeout=0
 ```
 
+### Investigation of failed jobs
+
+The "Investigation" tab of a failed job, also available as JSON from
+`/tests/<job id>/investigation_ajax`, lists what changed since the last good
+job in the same scenario. For jobs with failed modules the keys
+`failure_test_log` and `failure_needles_log` only list the commits to the files
+that are involved in the failure:
+
+- test code files named in the stack traces of the failed modules, or else the
+  module's own file
+- needles considered by failed screen checks and needles with the tags of those
+  checks
+
+The files are looked up in the commit the job was tested with
+(`TEST_GIT_HASH`, `NEEDLES_GIT_HASH`) within the checkouts on the web UI host,
+so later renames or removals do not change the result. Without a last good job
+the commits of the 5 days before the job started are listed. Each line is
+`<10-char sha> <title> (#<pull request>)`; the pull request is added if the
+commit was merged by a "Merge pull request" commit. The tab links it for
+GitHub and Gitea/Forgejo repositories.
+
+Instead of a log the keys contain a message if the tested commit is not
+available on the web UI host (e.g. for a `CASEDIR` that is a URL or absolute
+path) or if git took longer than `job_investigate_git_timeout` in total or
+failed. Pull request numbers are skipped if time runs out.
+
 <a id="carry-over"></a>
 
 ### Carry over of bug references from previous jobs in same scenario
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/openQA-5.1794057672.cbb4e678/lib/OpenQA/JobInvestigation.pm 
new/openQA-5.1794183862.c3440c51/lib/OpenQA/JobInvestigation.pm
--- old/openQA-5.1794057672.cbb4e678/lib/OpenQA/JobInvestigation.pm     
1970-01-01 01:00:00.000000000 +0100
+++ new/openQA-5.1794183862.c3440c51/lib/OpenQA/JobInvestigation.pm     
2026-11-09 01:24:22.000000000 +0100
@@ -0,0 +1,117 @@
+# Copyright SUSE LLC
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+package OpenQA::JobInvestigation;
+
+use Mojo::Base -strict, -signatures;
+
+use Exporter 'import';
+use List::Util 'uniq';
+use OpenQA::Utils 'run_cmd_with_log_return_error';
+
+our @EXPORT_OK = qw(
+  RETURN_CODE_TIMEOUT_EXCEEDED tree_file git_has_commit git_timed 
git_failure_message git_tree tagged_needles
+  annotate_pull_requests failed_test_files failed_needle_files
+);
+
+use constant JOB_INVESTIGATE_PULL_REQUESTS_MAX => 200;
+use constant RETURN_CODE_TIMEOUT_EXCEEDED => 124;    # see `man timeout`
+
+# returns the longest suffix of $path that is a file in the git tree (a hash 
reference of paths)
+sub tree_file ($tree, $path) {
+    my @parts = grep { length } split m{/}, $path // '';
+    for (; @parts; shift @parts) {
+        my $rel = join '/', @parts;
+        return $rel if $tree->{$rel};
+    }
+    return undef;
+}
+
+sub git_has_commit ($deadline, $dir, $hash) {
+    return 0 unless defined $hash && $hash =~ m/^[0-9a-f]{7,40}$/i;
+    return git_timed($deadline, $dir, ['cat-file', '-e', 
"$hash^{commit}"])->{status};
+}
+
+# runs git within the time left until $deadline (epoch seconds), which is 
shared by all commands of a request
+sub git_timed ($deadline, $dir, $args, %options) {
+    my $left = $deadline - time;
+    return {status => 0, return_code => RETURN_CODE_TIMEOUT_EXCEEDED, stdout 
=> '', stderr => ''} if $left <= 0;
+    return run_cmd_with_log_return_error(['timeout', $left, 'git', '-C', $dir, 
@$args], stdout => 'trace', %options);
+}
+
+sub git_failure_message ($return_code) {
+    return $return_code == RETURN_CODE_TIMEOUT_EXCEEDED
+      ? 'Computing changes timed out'
+      : 'Cannot list changes because of a git problem';
+}
+
+# returns the files of the commit $hash in $dir as hash reference or, as 
second value, why that is not possible
+sub git_tree ($deadline, $dir, $hash) {
+    my $unknown = 'Tested git commit is unknown or not available on this host, 
cannot list changes';
+    return (undef, $unknown) unless defined $hash && $hash =~ 
m/^[0-9a-f]{7,40}$/i;
+    my $res = git_timed($deadline, $dir, ['ls-tree', '-r', '-z', $hash]);
+    unless ($res->{status}) {
+        return (undef,
+            $res->{return_code} == RETURN_CODE_TIMEOUT_EXCEEDED
+            ? git_failure_message(RETURN_CODE_TIMEOUT_EXCEEDED)
+            : $unknown);
+    }
+    return (+{map { $_ => 1 } map { /^100\d{3} blob \S+\t(.*)$/s ? $1 : () } 
split /\0/, $res->{stdout}});
+}
+
+# returns the paths of the needles in the commit $hash that have one of the 
$tags, or the git error message
+sub tagged_needles ($deadline, $dir, $tree, $hash, $tags) {
+    my $pattern = join '|', map { s/([.+])/\\$1/gr } @$tags;
+    my @cmd = ('grep', '-l', '-z', '-E', '-e', qq{"($pattern)"}, $hash, '--', 
'*.json');
+    my $res = git_timed($deadline, $dir, \@cmd, expected_return_codes => {0 => 
1, 1 => 1});
+    my $return_code = $res->{return_code} // 255;
+    return (undef, git_failure_message($return_code)) if $return_code > 1;
+    return ([grep { $tree->{$_} } map { s/^\Q$hash\E://r } split /\0/, 
$res->{stdout}]);
+}
+
+# adds " (#<number>)" to commits that were merged via a "Merge pull request 
#<number>" merge commit, as far as
+# the time until $options{deadline} allows
+sub annotate_pull_requests ($dir, $log, $range, %options) {
+    my %pending = map { $_ => 1 } $log =~ /^([0-9a-f]{10}) (?!.*\(#\d+\)$)/mg;
+    return $log unless %pending;
+    my $deadline = $options{deadline};
+    my @since = $options{since} ? "--since=$options{since}" : ();
+    # commits on the first-parent line were not merged via pull request and 
would make us search all merges
+    my $direct = git_timed($deadline, $dir, ['rev-list', '--first-parent', 
'--no-merges', @since, $range]);
+    return $log unless $direct->{status};
+    delete $pending{$_} for $direct->{stdout} =~ /^([0-9a-f]{10})/mg;
+    return $log unless %pending;
+    my @merges = ('log', '--first-parent', '--merges', '--pretty=%H %s', '-' . 
JOB_INVESTIGATE_PULL_REQUESTS_MAX);
+    my $merges = git_timed($deadline, $dir, [@merges, @since, $range]);
+    return $log unless $merges->{status};
+    my %pull_request;
+    for my $merge (split /\n/, $merges->{stdout}) {
+        next unless $merge =~ m/^([0-9a-f]{40}) Merge pull request #(\d+) /;
+        my ($merge_hash, $number) = ($1, $2);
+        my $commits = git_timed($deadline, $dir, ['rev-list', '--no-merges', 
"$merge_hash^1..$merge_hash"]);
+        last unless $commits->{status};
+        $pull_request{$_} = $number for grep { delete $pending{$_} } 
$commits->{stdout} =~ /^([0-9a-f]{10})/mg;
+        last unless %pending;
+    }
+    return $log =~ s/^([0-9a-f]{10}) (.*)$/exists $pull_request{$1} ? "$1 $2 
(#$pull_request{$1})" : "$1 $2"/mger;
+}
+
+# the tested files of the failed modules, i.e. those of their stack traces, 
else the module's own file
+sub failed_test_files ($tree, $modules) {
+    my @files;
+    for my $module (@$modules) {
+        my ($traced, $script) = @$module;
+        my @found = map { tree_file($tree, $_) // () } @$traced;
+        push @files, @found ? @found : (tree_file($tree, $script) // ());
+    }
+    return [uniq @files];
+}
+
+# the needles considered by failed screen checks, their pngs and the needles 
with the tags of those checks
+sub failed_needle_files ($tree, $needles, $tagged) {
+    my @files = map { tree_file($tree, $_) // () } @$needles;
+    @files = uniq @files, @$tagged;
+    return [map { my $png = s/\.json$/.png/r; ($_, $png ne $_ && $tree->{$png} 
? $png : ()) } @files];
+}
+
+1;
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/openQA-5.1794057672.cbb4e678/lib/OpenQA/Schema/Result/Jobs.pm 
new/openQA-5.1794183862.c3440c51/lib/OpenQA/Schema/Result/Jobs.pm
--- old/openQA-5.1794057672.cbb4e678/lib/OpenQA/Schema/Result/Jobs.pm   
2026-11-07 14:21:12.000000000 +0100
+++ new/openQA-5.1794183862.c3440c51/lib/OpenQA/Schema/Result/Jobs.pm   
2026-11-09 01:24:22.000000000 +0100
@@ -16,6 +16,9 @@
 use OpenQA::App;
 use OpenQA::Constants qw(DOWNLOAD_PERMISSION);
 use OpenQA::Jobs::Constants;
+use OpenQA::JobInvestigation
+  qw(RETURN_CODE_TIMEOUT_EXCEEDED git_has_commit git_failure_message git_tree 
tagged_needles),
+  qw(annotate_pull_requests failed_test_files failed_needle_files);
 use OpenQA::JobDependencies::Constants;
 use OpenQA::Markdown 'markdown_to_html';
 use OpenQA::Setup;
@@ -37,8 +40,9 @@
 use OpenQA::File;
 use OpenQA::Parser 'parser';
 use OpenQA::WebSockets::Client;
-use List::Util qw(any all);
+use List::Util qw(any all uniq);
 use Scalar::Util qw(looks_like_number);
+use Time::Piece ();
 # The state and results constants are duplicated in the Python client:
 # if you change them or add any, please also update const.py.
 
@@ -53,7 +57,9 @@
 
 use constant MAX_LENGTH_REASON => 300;
 use constant JOB_INVESTIGATE_GIT_TIMEOUT => 20;
-use constant RETURN_CODE_TIMEOUT_EXCEEDED => 124;    # see `man timeout`
+use constant JOB_INVESTIGATE_FAILURE_WINDOW_DAYS => 5;
+use constant JOB_INVESTIGATE_FAILURE_MODULES_MAX => 3;
+use constant JOB_INVESTIGATE_FAILURE_FILES_MAX => 50;
 
 __PACKAGE__->table('jobs');
 __PACKAGE__->load_components(qw(InflateColumn::DateTime FilterColumn 
Timestamps));
@@ -1938,14 +1944,20 @@
     return "Invalid range $refspec_range" if $refspec_range =~ 
m/UNKNOWN|unreadable git hash/;
     my $limit = $options{limit};
     my $stat = $options{stat} // '300,200';
+    my @abbrev = $options{abbrev} ? "--abbrev=$options{abbrev}" : ();
+    my @since = $options{since} ? "--since=$options{since}" : ();
+    my @pickaxe = $options{pickaxe} ? "-G$options{pickaxe}" : ();
+    my @paths = $options{paths} ? ('--', @{$options{paths}}) : ();
+    my @timeout = $options{timeout} ? ('timeout', $options{timeout}) : ();
     my $res = run_cmd_with_log_return_error(
         [
-            'git', '-C', $dir, 'log',
-            ($limit ? "-$limit" : ()), "--stat=$stat", '--pretty=oneline', 
'--abbrev-commit',
-            '--no-merges', $refspec_range
+            @timeout, 'git', '-C', $dir, 'log', ($limit ? "-$limit" : ()),
+            "--stat=$stat", '--pretty=oneline',
+            '--abbrev-commit', @abbrev, '--no-merges', @since, @pickaxe, 
$refspec_range, @paths
         ],
         stdout => 'trace'
     );
+    ${$options{failed}} = $res->{return_code} // 255 if $options{failed} && 
!$res->{status};
     # regardless of success or not the output contains the information we need
     return $res->{stdout} . $res->{stderr};
 }
@@ -1982,6 +1994,96 @@
     return $out;
 }
 
+# returns the test code paths, needle paths and tags involved in the failed 
modules, found via their stack
+# traces and failed screen steps, as array references; for the test code as 
per module [trace paths, script]
+sub _failed_module_inputs ($self) {
+    my (@modules, @needles, @tags);
+    my %attrs = (order_by => 't_updated', rows => 
JOB_INVESTIGATE_FAILURE_MODULES_MAX);
+    my $modules = $self->modules->search({result => 'failed'}, \%attrs);
+    while (my $module = $modules->next) {
+        my @steps = grep { ($_->{result} // '') eq 'fail' } 
@{$module->results->{details} // []};
+        my @traced = map { /(\S+\.(?:pm|py)) line \d+/g } map { 
$_->{text_data} // () } @steps;
+        push @modules, [\@traced, $module->script];
+        for my $step (@steps) {
+            push @needles, map { $_->{json} // () } @{$step->{needles} // []};
+            my @missing = ($step->{text_data} // '') =~ /no candidate needle 
with tag\(s\) '([^']+)'/g;
+            push @tags, grep { /^[A-Za-z0-9_.:+-]+$/ } @{$step->{tags} // []}, 
map { split /[\s,]+/ } @missing;
+        }
+    }
+    return \@modules, [uniq @needles], [uniq @tags];
+}
+
+sub _failure_window_start ($self) {
+    my $started = $self->get_column('t_started');
+    my $epoch = $started ? Time::Piece->strptime($started =~ s/\..*//r, 
'%Y-%m-%d %H:%M:%S')->epoch : time;
+    return $epoch - JOB_INVESTIGATE_FAILURE_WINDOW_DAYS * 24 * 60 * 60;
+}
+
+# lists commits to the files of the $selections (git log options) between the 
last good and this job, or else
+# within the window before this job started; returns a message instead if git 
failed
+sub _failure_log ($self, $dir, $self_hash, $prev_hash, $selections, %options) {
+    my $deadline = $options{deadline};
+    my %git = (limit => $options{limit}, abbrev => 10);
+    my $has_range = git_has_commit($deadline, $dir, $prev_hash);
+    my $range = $has_range ? "$prev_hash..$self_hash" : $self_hash;
+    $git{since} = $self->_failure_window_start unless $has_range;
+    my $log = '';
+    for my $selection (@$selections) {
+        my $left = $deadline - time;
+        return git_failure_message(RETURN_CODE_TIMEOUT_EXCEEDED) if $left <= 0;
+        my $failed;
+        $log .= $self->git_log_diff($dir, $range, %git, timeout => $left, 
failed => \$failed, %$selection);
+        return git_failure_message($failed) if $failed;
+    }
+    my %seen;
+    $log = join '', grep { !$seen{/^(\S+)/ ? $1 : ''}++ } split 
/(?=^[0-9a-f]{10} )/m, $log;
+    return $log ? annotate_pull_requests($dir, $log, $range, since => 
$git{since}, deadline => $deadline) : '';
+}
+
+# commits to the files named in the stack traces and to the needles of the 
failed screen steps
+sub _failure_investigation ($self, $inv, $self_vars, $prev_vars, %args) {
+    return unless $self->has_failed_modules && $self->result_dir && 
%$self_vars;
+    my $timeout = 
OpenQA::App->singleton->config->{global}->{job_investigate_git_timeout}
+      // JOB_INVESTIGATE_GIT_TIMEOUT;
+    my $deadline = time + $timeout;
+    my $effective_distri = effective_distri($self_vars);
+    my $casedir = testcasedir($effective_distri, $self->VERSION);
+    my $needles_dir = needledir($effective_distri, $self->VERSION);
+    my ($modules, $needles, $tags) = $self->_failed_module_inputs;
+    my ($test_hash, $needles_hash) = map { $self_vars->{$_} } qw(TEST_GIT_HASH 
NEEDLES_GIT_HASH);
+    my ($prev_test, $prev_needles) = map { $prev_vars->{$_} } qw(TEST_GIT_HASH 
NEEDLES_GIT_HASH);
+    my %log = (limit => $args{git_limit}, deadline => $deadline);
+    if (@$modules) {
+        my ($tree, $error) = git_tree($deadline, $casedir, $test_hash);
+        my $files = $tree ? failed_test_files($tree, $modules) : [];
+        splice @$files, JOB_INVESTIGATE_FAILURE_FILES_MAX;
+        my @selections = ({paths => [map { ":(literal)$_" } @$files]});
+        $inv->{failure_test_log}
+          = $error
+          || (@$files ? $self->_failure_log($casedir, $test_hash, $prev_test, 
\@selections, %log) : '')
+          || 'No recent changes to the test files involved in the failure'
+          if $error
+          || @$files;
+    }
+    if (@$needles || @$tags) {
+        my ($tree, $error) = git_tree($deadline, $needles_dir, $needles_hash);
+        my $tagged = [];
+        ($tagged, $error) = tagged_needles($deadline, $needles_dir, $tree, 
$needles_hash, $tags) if $tree && @$tags;
+        my $files = $tree && !$error ? failed_needle_files($tree, $needles, 
$tagged) : [];
+        splice @$files, JOB_INVESTIGATE_FAILURE_FILES_MAX;
+        my @selections;
+        push @selections, {paths => [map { ":(literal)$_" } @$files]} if 
@$files;
+        my $pattern = join '|', map { s/([.+])/\\$1/gr } @$tags;
+        push @selections, {pickaxe => qq{"($pattern)"}, paths => ['*.json']} 
if @$tags;
+        $inv->{failure_needles_log}
+          = $error
+          || $self->_failure_log($needles_dir, $needles_hash, $prev_needles, 
\@selections, %log)
+          || 'No recent changes to the needles involved in the failure'
+          if $error
+          || @selections;
+    }
+}
+
 =head2 investigate
 
 Find pointers for investigation on failures, e.g. what changed vs. a "last
@@ -1994,8 +2096,18 @@
     # this job's own isolated history (e.g. the same PR), not an unrelated one
     my $isolation_keys = exists $args{isolation_keys} ? $args{isolation_keys} 
: undef;
     my @previous = $self->_previous_scenario_jobs(undef, {}, $isolation_keys);
-    return {error => 'No previous job in this scenario, cannot provide hints'} 
unless @previous;
+    unless (@previous) {
+        my $vars_file = $self->result_dir ? eval { path($self->result_dir, 
'vars.json')->slurp } : undef;
+        my $vars = decode_json($vars_file // '{}');
+        my %inv = (
+            error => 'No previous job in this scenario, cannot provide hints',
+            testgiturl => git_commit_url($vars->{TEST_GIT_URL}),
+            needlegiturl => git_commit_url($vars->{NEEDLES_GIT_URL}));
+        $self->_failure_investigation(\%inv, $vars, {}, %args);
+        return \%inv;
+    }
     my %inv;
+    my $prev_vars = {};
     return {error => 'No result directory available for current job'} unless 
$self->result_dir();
     my $ignore = 
OpenQA::App->singleton->config->{global}->{job_investigate_ignore};
     my $self_file = eval { Mojo::File->new($self->result_dir(), 
'vars.json')->slurp };
@@ -2010,6 +2122,7 @@
         $inv{last_good} = {type => 'link', link => '/tests/' . $prev->id, text 
=> $prev->id};
         last unless $prev->result_dir;
         my $prev_file = eval { Mojo::File->new($prev->result_dir(), 
'vars.json')->slurp };
+        $prev_vars = decode_json($prev_file) if $prev_file;
         $inv{diff_packages_to_last_good} = $self->packages_diff($prev, 
$ignore, 'worker_packages.txt');
         $inv{diff_sut_packages_to_last_good} = $self->packages_diff($prev, 
$ignore, 'sut_packages.txt');
         last unless $self_file && $prev_file;
@@ -2017,7 +2130,6 @@
         # files missing. This is a best-effort approach.
         my $diff = eval { diff(\$prev_file, \$self_file, {CONTEXT => 0}) };
         $inv{diff_to_last_good} = join "\n", grep { !/(?:^@@|$ignore)/ } split 
/\n/, $diff;
-        my $prev_vars = decode_json($prev_file // '{}');
         my $effective_distri = effective_distri($self_vars);
         my $casedir = testcasedir($effective_distri, $self->VERSION);
         my $refspec_range = 
"$prev_vars->{TEST_GIT_HASH}..$self_vars->{TEST_GIT_HASH}";
@@ -2038,6 +2150,7 @@
         last;
     }
     $inv{last_good} //= 'not found';
+    $self->_failure_investigation(\%inv, $self_vars, $prev_vars, %args);
     $inv{testgiturl} = git_commit_url($self_vars->{TEST_GIT_URL});
     $inv{needlegiturl} = git_commit_url($self_vars->{NEEDLES_GIT_URL});
     return \%inv;
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/openQA-5.1794057672.cbb4e678/t/10-jobs.t 
new/openQA-5.1794183862.c3440c51/t/10-jobs.t
--- old/openQA-5.1794057672.cbb4e678/t/10-jobs.t        2026-11-07 
14:21:12.000000000 +0100
+++ new/openQA-5.1794183862.c3440c51/t/10-jobs.t        2026-11-09 
01:24:22.000000000 +0100
@@ -16,6 +16,7 @@
 use autodie ':all';
 use File::Copy;
 use OpenQA::Jobs::Constants;
+use OpenQA::JobInvestigation 'annotate_pull_requests';
 use OpenQA::Test::Case;
 use Test::MockModule 'strict';
 use Test::Mojo;
@@ -538,6 +539,175 @@
         like $inv->{diff_packages_to_last_good}, qr/^\+python/m, 'diff 
packages for job is shown';
     };
 
+    subtest 'investigation lists recent changes to files involved in the 
failure' => sub {
+        my $repo = tempdir;
+        my $git = sub (@args) { system 'git', '-C', "$repo", '-c', 
'commit.gpgsign=false', @args };
+        local @ENV{qw(GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_COMMITTER_NAME 
GIT_COMMITTER_EMAIL)}
+          = qw(t [email protected] t [email protected]);
+        my $commit = sub ($file, $message, $content = $message, $date = time) {
+            local @ENV{qw(GIT_AUTHOR_DATE GIT_COMMITTER_DATE)} = ("$date 
+0000") x 2;
+            $repo->child($file)->dirname->make_path;
+            $repo->child($file)->spew($content);
+            $git->(qw(add -A));
+            $git->(qw(commit -q -m), $message);
+            chomp(my $hash = qx{git -C "$repo" rev-parse HEAD});
+            return $hash;
+        };
+        $git->(qw(init -q -b main));
+        $commit->('tests/x.pm', 'Ancient change', 'Ancient change', 
946_684_800);
+        $git->(qw(switch -q -c feature));
+        $commit->('tests/x.pm', 'Break x');
+        $commit->('tests/unrelated.pm', 'Unrelated');
+        $git->(qw(switch -q main));
+        $git->(qw(merge -q --no-ff -m), 'Merge pull request #42 from 
fork/feature', 'feature');
+        chomp(my $merge = qx{git -C "$repo" rev-parse HEAD});
+        $commit->('tests/x.pm', 'Squash fix (#43)');
+        $commit->('tests/old.pm', 'Add old');
+        $commit->('lib/helper.pm', 'Change helper');
+        $commit->('needles/foo-1.png', 'Add foo needle png');
+        $commit->('needles/foo-1.json', 'Add foo needle', '{"tags": 
["foo-tag"]}');
+        $commit->('needles/bar.json', 'Add bar needle', '{"tags": ["bar"]}');
+        $commit->('needles/foo-2.json', 'Retag needle', '{"tags": ["foo-tag", 
"other"]}');
+        $commit->('needles/area.json', 'Add area needle', '{"tags": 
["area-tag"], "area": [{"xpos": 1}]}');
+        $commit->('needles/gone.json', 'Add gone needle', '{"tags": 
["removed-tag"]}');
+        $commit->('needles/foo-1.png', 'Update foo png only');
+        $commit->('needles/area.json', 'Move area', '{"tags": ["area-tag"], 
"area": [{"xpos": 2}]}');
+        my $tested = $commit->('needles/gone.json', 'Drop tag from gone 
needle', '{"tags": ["other"]}');
+        $git->(qw(rm -q tests/old.pm));
+        $git->(qw(commit -q -m), 'Remove old');
+        my $head = $commit->('tests/later.pm', 'Later');
+
+        my $trace = join "\n", '# Test died: oops', '--- # stack trace',
+          "testapi::assert_script_run('x') called at tests/x.pm line 19",
+          'prepare::y at /var/lib/openqa/pool/1/tests/old.pm line 7', 'at 
../outside.pm line 3',
+          'at .git/hook.pm line 1';
+        my $needle_step = {
+            result => 'fail',
+            tags => ['foo-tag', 'v1.2'],
+            needles => [{json => '/var/lib/openqa/needles/foo-1.json'}]};
+        my $text_step = {result => 'fail', text_data => "no candidate needle 
with tag(s) 'bar, removed-tag'"};
+        my $area_step = {result => 'fail', tags => ['area-tag']};
+        $job->update_module(
+            'b',
+            {
+                result => 'fail',
+                details => [{result => 'fail', text_data => $trace}, 
$needle_step, $text_step, $area_step]});
+        $job->insert_module({name => 'c', category => 'c', script => 
'tests/x.pm', flags => {}});
+        $job->update_module('c', {result => 'fail', details => []});
+        my $needle_trace = join "\n", "# Test died: no candidate needle with 
tag(s) 'foo-tag' matched",
+          '--- # stack trace',
+          'testapi::assert_screen("foo-tag") called at lib/helper.pm line 94',
+          'tests::other::run() called at tests/other.pm line 5';
+        $job->insert_module({name => 'd', category => 'd', script => 
'tests/other.pm', flags => {}});
+        $job->update_module('d', {result => 'fail', details => [{result => 
'fail', text_data => $needle_trace}]});
+
+        my $job_mock = Test::MockModule->new('OpenQA::Schema::Result::Jobs', 
no_auto => 1);
+        $job_mock->redefine(testcasedir => sub (@) { "$repo" });
+        $job_mock->redefine(needledir => sub (@) { 
$repo->child('needles')->to_string });
+        my $set_vars = sub ($j, $hash, %extra) {
+            path($j->result_dir, 'vars.json')
+              ->spew(encode_json {TEST_GIT_HASH => $hash, NEEDLES_GIT_HASH => 
$hash, %extra});
+        };
+        my $last_good = ($job->_previous_scenario_jobs)[1];
+        $set_vars->($job, $tested);
+
+        subtest 'window before the job started without last good vars.json' => 
sub {
+            path($last_good->result_dir, 'vars.json')->remove;
+            my $inv = $job->investigate;
+            my $log = $inv->{failure_test_log};
+            like $log, qr/^[0-9a-f]{10} Break x \(#42\)$/m, 'commit merged via 
merge commit links its pull request';
+            like $log, qr/^[0-9a-f]{10} Squash fix \(#43\)$/m, 'pull request 
of squashed commit not added twice';
+            like $log, qr/tests\/x\.pm \|/, 'diff stat of involved file shown';
+            like $log, qr/^[0-9a-f]{10} Add old$/m, 'file removed after the 
tested commit still considered';
+            like $log, qr/^[0-9a-f]{10} Change helper$/m, 'shared library of a 
failed needle check considered';
+            unlike $log, qr/Ancient change/, 'commit older than the window not 
listed';
+            unlike $log, qr/Unrelated|Later|Remove old|outside|hook/, 
'uninvolved or later changes ignored';
+            $log = $inv->{failure_needles_log};
+            like $log, qr/^[0-9a-f]{10} Add foo needle$/m, 'commit of 
candidate needle listed';
+            like $log, qr/^[0-9a-f]{10} Update foo png only$/m, 'commit 
changing only the png of a candidate listed';
+            like $log, qr/^[0-9a-f]{10} Retag needle$/m, 'commit changing a 
needle with the failed tag listed';
+            like $log, qr/^[0-9a-f]{10} Add bar needle$/m, 'tag from text of 
failed step considered';
+            like $log, qr/^[0-9a-f]{10} Move area$/m, 'commit not touching the 
tag lines of a needle with it listed';
+            like $log, qr/^[0-9a-f]{10} Drop tag from gone needle$/m, 'commit 
removing the failed tag listed';
+            is scalar(() = $log =~ /Add foo needle$/mg), 1, 'commits found 
twice only listed once';
+        };
+
+        subtest 'range to the last good job' => sub {
+            $set_vars->($last_good, $merge);
+            my $inv = $job->investigate(git_limit => 200);
+            unlike $inv->{failure_test_log}, qr/Break x/, 'commits before the 
last good job not listed';
+            like $inv->{failure_test_log}, qr/Squash fix \(#43\)/, 'commits 
after the last good job listed';
+            $set_vars->($last_good, $tested);
+            $inv = $job->investigate(git_limit => 200);
+            is $inv->{failure_test_log}, 'No recent changes to the test files 
involved in the failure', 'no changes';
+            like $inv->{failure_needles_log}, qr/^No recent changes to the 
needles/, 'no needle changes';
+        };
+
+        subtest 'tested commit not available' => sub {
+            $set_vars->($job, 'a' x 40);
+            my $inv = $job->investigate(git_limit => 200);
+            like $inv->{failure_test_log}, qr/unknown or not available/, 
'message about missing commit';
+            like $inv->{failure_needles_log}, qr/unknown or not available/, 
'message for needles as well';
+            $set_vars->($job, $tested);
+        };
+
+        subtest 'git problems are not reported as missing changes' => sub {
+            my $global = $t->app->config->{global};
+            {
+                local $global->{job_investigate_git_timeout} = 0;
+                my $inv = $job->investigate(git_limit => 200);
+                is $inv->{failure_test_log}, 'Computing changes timed out', 
'timeout of test log reported';
+                is $inv->{failure_needles_log}, 'Computing changes timed out', 
'timeout of needles log reported';
+            }
+            my $run_cmd = 
\&OpenQA::Schema::Result::Jobs::run_cmd_with_log_return_error;
+            $job_mock->redefine(
+                run_cmd_with_log_return_error => sub ($cmd, %args) {
+                    return {status => 0, return_code => 128, stdout => '', 
stderr => 'fatal'}
+                      if grep { $_ eq 'log' } @$cmd;
+                    return $run_cmd->($cmd, %args);
+                });
+            my $inv = $job->investigate(git_limit => 200);
+            is $inv->{failure_test_log}, 'Cannot list changes because of a git 
problem', 'failure of test log reported';
+            is $inv->{failure_needles_log}, 'Cannot list changes because of a 
git problem', 'needles failure reported';
+            $job_mock->redefine(run_cmd_with_log_return_error => $run_cmd);
+        };
+
+        subtest 'pull requests are looked up as far as time allows' => sub {
+            chomp(my $short = qx{git -C "$repo" rev-parse --short=10 $head});
+            my $log = "$short Later\n";
+            my $run_cmd = 
\&OpenQA::JobInvestigation::run_cmd_with_log_return_error;
+            my @commands;
+            my $investigation_mock = 
Test::MockModule->new('OpenQA::JobInvestigation', no_auto => 1);
+            $investigation_mock->redefine(
+                run_cmd_with_log_return_error => sub ($cmd, %args) { push 
@commands, $cmd; $run_cmd->($cmd, %args) });
+            is annotate_pull_requests("$repo", $log, $head, deadline => time + 
20), $log, 'direct commit as is';
+            is scalar @commands, 1, 'merge commits not searched for a commit 
on the first-parent line';
+            @commands = ();
+            is annotate_pull_requests("$repo", $log, $head, deadline => time - 
1), $log, 'log kept if too late';
+            is scalar @commands, 0, 'no git command run after the deadline';
+        };
+
+        subtest 'no previous job in the scenario' => sub {
+            $job_mock->redefine(_previous_scenario_jobs => sub (@) { () });
+            $set_vars->(
+                $job, $tested,
+                TEST_GIT_URL => 'https://github.com/o/r.git',
+                NEEDLES_GIT_URL => '[email protected]:o/n.git'
+            );
+            my $inv = $job->investigate(git_limit => 200);
+            like $inv->{error}, qr/No previous job/, 'error reported';
+            like $inv->{failure_test_log}, qr/^[0-9a-f]{10} Break x 
\(#42\)$/m, 'test changes of the window listed';
+            like $inv->{failure_needles_log}, qr/^[0-9a-f]{10} Move area$/m, 
'needle changes of the window listed';
+            is $inv->{testgiturl}, 'https://github.com/o/r/commit/', 
'repository URL of tests provided for links';
+            is $inv->{needlegiturl}, 'https://gitea.example.com/o/n/commit/', 
'repository URL of needles provided';
+            path($job->result_dir, 'vars.json')->spew('{');
+            throws_ok { $job->investigate(git_limit => 200) } qr/expected, at 
character offset/,
+              'invalid vars.json is not ignored';
+            $job_mock->unmock('_previous_scenario_jobs');
+        };
+        $job_mock->unmock_all;
+    };
+
     subtest 'external hook is called on done job if specified' => sub {
         my $task_mock = Test::MockModule->new('OpenQA::Task::Job::HookScript', 
no_auto => 1);
         $task_mock->redefine(
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/openQA-5.1794057672.cbb4e678/t/16-utils-runcmd.t 
new/openQA-5.1794183862.c3440c51/t/16-utils-runcmd.t
--- old/openQA-5.1794057672.cbb4e678/t/16-utils-runcmd.t        2026-11-07 
14:21:12.000000000 +0100
+++ new/openQA-5.1794183862.c3440c51/t/16-utils-runcmd.t        2026-11-09 
01:24:22.000000000 +0100
@@ -122,6 +122,19 @@
           'git_diff - full filename';
         like OpenQA::Schema::Result::Jobs::git_diff(undef, $empty_tmp_dir, 
'HEAD', limit => 3, stat => '15,10'),
           qr/\.\.\.ile.txt/, 'git_diff - filename shortened';
+        my $failed;
+        like OpenQA::Schema::Result::Jobs::git_log_diff(
+            undef, $empty_tmp_dir, 'HEAD',
+            timeout => 10,
+            failed => \$failed
+          ),
+          qr/$msg/, 'git_log_diff - commit mentioned with timeout';
+        ok !$failed, 'no failure reported on success';
+        combined_like {
+            OpenQA::Schema::Result::Jobs::git_log_diff(undef, $empty_tmp_dir, 
'unknown-ref', failed => \$failed)
+        }
+        qr/cmd returned 128/, 'failure logged';
+        is $failed, 128, 'return code of failed git reported';
     };
 
     subtest 'cache_ref' => sub {
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/openQA-5.1794057672.cbb4e678/t/ui/18-tests-details.t 
new/openQA-5.1794183862.c3440c51/t/ui/18-tests-details.t
--- old/openQA-5.1794057672.cbb4e678/t/ui/18-tests-details.t    2026-11-07 
14:21:12.000000000 +0100
+++ new/openQA-5.1794183862.c3440c51/t/ui/18-tests-details.t    2026-11-09 
01:24:22.000000000 +0100
@@ -1087,6 +1087,28 @@
     $value = '9b8aaf060 Containers: Inc…\n stat1\n stat2\na54a4bb34Remove 
select…\n stat3\n stat4\n';
     $links = $driver->execute_script(qq{return githashToLink("$value", 
'test/')});
     is_deeply $links, undef, 'null returned as second line is invalid' or 
always_explain $links;
+
+    my $html = sub ($msg, $repo) {
+        $driver->execute_script(
+'const d = document.createElement("div"); 
d.appendChild(createCommitMessageLink(arguments[0], arguments[1]));'
+              . 'return d.innerHTML;',
+            $msg, $repo
+        );
+    };
+    is $html->('Fix <b>foo</b> (#42)', 'https://github.com/o/r/commit/'),
+      'Fix &lt;b&gt;foo&lt;/b&gt; (<a 
href="https://github.com/o/r/pull/42";>#42</a>)',
+      'pull request linked and message escaped for GitHub';
+    like $html->('Fix foo (#42)', 'https://gitea.example.com/o/r/commit/'),
+      qr{href="https://gitea.example.com/o/r/pulls/42"},
+      'pull request linked for Gitea';
+    like $html->('Fix foo (#42)', 'https://src.opensuse.org/o/r/commit/'),
+      qr{href="https://src.opensuse.org/o/r/pulls/42"},
+      'pull request linked for src.opensuse.org';
+    is $html->('Fix foo (#42)', 'https://gitlab.com/o/r/commit/'), 'Fix foo 
(#42)', 'no link for GitLab';
+    is $html->('Fix foo (#42)', 'https://github.example.com/o/r/commit/'), 
'Fix foo (#42)', 'no link for unknown host';
+    is $html->('Fix foo (#42)', 'https://github.com/o/r/'), 'Fix foo (#42)', 
'no link for unexpected URL';
+    is $html->('Fix foo (#42)', 'undefined'), 'Fix foo (#42)', 'no link 
without repository URL';
+    is $html->('Fix foo', 'https://github.com/o/r/commit/'), 'Fix foo', 
'message without pull request unchanged';
 };
 
 subtest 'additional investigation notes provided on new failed' => sub {
@@ -1114,6 +1136,8 @@
     $driver->find_element('#investigation_status_entry')
       ->text_like(qr/error\nNo previous job in this scenario, cannot provide 
hints/,
         'linked to investigation tab directly');
+    is 
$driver->find_element_by_id('investigation')->get_attribute('data-testgiturl'), 
undef,
+      'no repository URL set if unknown';
     $driver->find_element_by_xpath(q{//div[@class='tab-content']})
       ->text_unlike(qr/Investigate the first bad test/, 'no alert shown for 
first bad itself');
 };

++++++ openQA.obsinfo ++++++
--- /var/tmp/diff_new_pack.Di3F2J/_old  2026-11-09 16:06:01.537288749 +0100
+++ /var/tmp/diff_new_pack.Di3F2J/_new  2026-11-09 16:06:01.542288959 +0100
@@ -1,5 +1,5 @@
 name: openQA
-version: 5.1794057672.cbb4e678
-mtime: 1794057672
-commit: cbb4e67823bfcd5b54655489ec40dde9d3c82c44
+version: 5.1794183862.c3440c51
+mtime: 1794183862
+commit: c3440c5101e56a2ff28383650d26026c414618db
 

Reply via email to