[Concept,16/16] pickman: Strip null bytes from CI job logs

Message ID 20260222154303.2851319-17-sjg@u-boot.org
State New
Headers
Series pickman: Support monitoring and fixing pipeline failures |

Commit Message

Simon Glass Feb. 22, 2026, 3:42 p.m. UTC
  From: Simon Glass <simon.glass@canonical.com>

CI job traces can contain embedded null bytes, which cause a
ValueError ("embedded null byte") when the prompt string is passed
to the Claude Agent SDK subprocess. Strip null bytes from the trace
after decoding.

Co-developed-by: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Simon Glass <simon.glass@canonical.com>
---

 tools/pickman/ftest.py      | 35 +++++++++++++++++++++++++++++++++++
 tools/pickman/gitlab_api.py |  1 +
 2 files changed, 36 insertions(+)
  

Patch

diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py
index ced2c79ac87..5a4d0c433dc 100644
--- a/tools/pickman/ftest.py
+++ b/tools/pickman/ftest.py
@@ -5787,6 +5787,41 @@  class TestGetFailedJobs(unittest.TestCase):
         self.assertIn('line 499', result[0].log_tail)
 
 
+    @mock.patch.object(gitlab, 'get_remote_url',
+                       return_value=TEST_SSH_URL)
+    @mock.patch.object(gitlab, 'get_token', return_value='test-token')
+    @mock.patch.object(gitlab, 'AVAILABLE', True)
+    def test_null_bytes_stripped(self, _mock_token, _mock_url):
+        """Test that null bytes in job logs are stripped"""
+        trace_bytes = b'before\x00after\nline2\x00end\n'
+
+        mock_job = self._make_mock_job(
+            1, 'build:sandbox', 'build', 'https://gitlab.com/job/1',
+            trace_bytes)
+
+        mock_full_job = mock.MagicMock()
+        mock_full_job.trace.return_value = trace_bytes
+
+        mock_pipeline = mock.MagicMock()
+        mock_pipeline.jobs.list.return_value = [mock_job]
+
+        mock_project = mock.MagicMock()
+        mock_project.pipelines.get.return_value = mock_pipeline
+        mock_project.jobs.get.return_value = mock_full_job
+
+        mock_glab = mock.MagicMock()
+        mock_glab.projects.get.return_value = mock_project
+
+        with mock.patch('gitlab.Gitlab', return_value=mock_glab):
+            with terminal.capture():
+                result = gitlab.get_failed_jobs('ci', 100)
+
+        self.assertEqual(len(result), 1)
+        self.assertNotIn('\0', result[0].log_tail)
+        self.assertIn('beforeafter', result[0].log_tail)
+        self.assertIn('line2end', result[0].log_tail)
+
+
 class TestBuildPipelineFixPrompt(unittest.TestCase):
     """Tests for build_pipeline_fix_prompt function."""
 
diff --git a/tools/pickman/gitlab_api.py b/tools/pickman/gitlab_api.py
index d9635d392ee..81918c80c3c 100644
--- a/tools/pickman/gitlab_api.py
+++ b/tools/pickman/gitlab_api.py
@@ -478,6 +478,7 @@  def get_failed_jobs(remote, pipeline_id, max_log_lines=200):
             full_job = project.jobs.get(job.id)
             try:
                 trace = full_job.trace().decode('utf-8', errors='replace')
+                trace = trace.replace('\0', '')
                 lines = trace.splitlines()
                 log_tail = '\n'.join(lines[-max_log_lines:])
             except (AttributeError, gitlab.exceptions.GitlabError):