You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@devlake.apache.org by kl...@apache.org on 2026/04/29 04:05:13 UTC

(incubator-devlake) branch main updated: fix(azuredevops_go): handle empty/invalid timeline responses for YAML… (#8839)

This is an automated email from the ASF dual-hosted git repository.

klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-devlake.git


The following commit(s) were added to refs/heads/main by this push:
     new 3472cc364 fix(azuredevops_go): handle empty/invalid timeline responses for YAML… (#8839)
3472cc364 is described below

commit 3472cc3644f3684b3ae3e6599e359dcc58d2bf5d
Author: Cornelius Schuchardt <co...@gmail.com>
AuthorDate: Wed Apr 29 06:05:08 2026 +0200

    fix(azuredevops_go): handle empty/invalid timeline responses for YAML… (#8839)
    
    * fix(azuredevops_go): handle empty/invalid timeline responses for YAML-broken builds
    
    Builds that fail due to YAML syntax errors in pipeline definitions return
    an empty or non-JSON body from the Timeline API (200 OK with no usable
    content). Previously this caused ParseRawMessageFromRecords to propagate
    a hard error that aborted the entire collectApiTimelineRecords subtask.
    
    Two complementary fixes:
    
    1. Add ignoreInvalidTimelineResponse AfterResponse handler that supersedes
       ignoreDeletedBuilds: it still skips 404s (deleted builds) and now also
       skips responses with empty or non-JSON bodies by returning
       api.ErrIgnoreAndContinue, leaving the rest of the pipeline intact.
       The body is peeked and then restored via io.NopCloser so the downstream
       ResponseParser can still read it when the body is valid JSON.
    
    2. Extend the DB query in CollectRecords to exclude builds with
       result = 'none' in addition to result = 'failed'. Builds broken by
       YAML syntax errors are categorised as 'none' by Azure DevOps (already
       mapped to RESULT_FAILURE in cicdBuildResultRule), so fetching their
       timeline is both unnecessary and the primary source of the bad
       responses. Using NOT IN ? with a []string slice follows the same
       pattern used in blueprint_helper.go.
    
    Unit tests added for ignoreInvalidTimelineResponse covering: 404,
    empty body, non-JSON body, valid JSON (nil error + body restored),
    and valid empty JSON object.
    
    Fixes: #8838
    Signed-off-by: Cornelius Schuchardt <co...@beck.de>
    
    * fix(azuredevops_go): remove unused ignoreDeletedBuilds function
    
    The ignoreDeletedBuilds function was replaced by
    ignoreInvalidTimelineResponse
    in ci_cd_timeline_records_collector.go but was left behind in shared.go.
    This caused the 'unused' linter to fail with exit code 2.
    
    * fix: resolve govet non-constant format string warnings
    
    Use constant format verbs instead of dynamic strings passed directly
    as format arguments to avoid govet printf-family warnings:
    
    - core/migration/linter: fmt.Errorf with string concat replaced by %s verb
    - impls/logruslog: Warn/Error pass pre-formatted message via "%s" arg
      instead of using it directly as the format string
    
    ---------
    
    Signed-off-by: Cornelius Schuchardt <co...@beck.de>
---
 backend/core/migration/linter/main.go              |  2 +-
 backend/impls/logruslog/logger.go                  |  4 +-
 .../tasks/ci_cd_timeline_records_collector.go      |  4 +-
 backend/plugins/azuredevops_go/tasks/shared.go     | 29 ++++++++-
 .../plugins/azuredevops_go/tasks/shared_test.go    | 72 ++++++++++++++++++++++
 5 files changed, 103 insertions(+), 8 deletions(-)

diff --git a/backend/core/migration/linter/main.go b/backend/core/migration/linter/main.go
index bf9190b72..11aae870c 100644
--- a/backend/core/migration/linter/main.go
+++ b/backend/core/migration/linter/main.go
@@ -51,7 +51,7 @@ func firstLineFromFile(path string) string {
 	for scanner.Scan() {
 		return scanner.Text()
 	}
-	panic(fmt.Errorf("empty file: " + path))
+	panic(fmt.Errorf("empty file: %s", path))
 }
 
 const (
diff --git a/backend/impls/logruslog/logger.go b/backend/impls/logruslog/logger.go
index 857d75ac2..88287c9f5 100644
--- a/backend/impls/logruslog/logger.go
+++ b/backend/impls/logruslog/logger.go
@@ -72,11 +72,11 @@ func (l *DefaultLogger) Info(format string, a ...interface{}) {
 }
 
 func (l *DefaultLogger) Warn(err error, format string, a ...interface{}) {
-	l.Log(log.LOG_WARN, formatMessage(err, format, a...))
+	l.Log(log.LOG_WARN, "%s", formatMessage(err, format, a...))
 }
 
 func (l *DefaultLogger) Error(err error, format string, a ...interface{}) {
-	l.Log(log.LOG_ERROR, formatMessage(err, format, a...))
+	l.Log(log.LOG_ERROR, "%s", formatMessage(err, format, a...))
 }
 
 func (l *DefaultLogger) SetStream(config *log.LoggerStreamConfig) {
diff --git a/backend/plugins/azuredevops_go/tasks/ci_cd_timeline_records_collector.go b/backend/plugins/azuredevops_go/tasks/ci_cd_timeline_records_collector.go
index 07d4b7627..9ac766917 100644
--- a/backend/plugins/azuredevops_go/tasks/ci_cd_timeline_records_collector.go
+++ b/backend/plugins/azuredevops_go/tasks/ci_cd_timeline_records_collector.go
@@ -49,7 +49,7 @@ func CollectRecords(taskCtx plugin.SubTaskContext) errors.Error {
 	cursor, err := db.Cursor(
 		dal.Select("azuredevops_id"),
 		dal.From(models.AzuredevopsBuild{}.TableName()),
-		dal.Where("repository_id = ? and connection_id=? and result != ?", data.Options.RepositoryId, data.Options.ConnectionId, "failed"),
+		dal.Where("repository_id = ? and connection_id=? and result NOT IN ?", data.Options.RepositoryId, data.Options.ConnectionId, []string{"failed", "none"}),
 	)
 	if err != nil {
 		return err
@@ -68,7 +68,7 @@ func CollectRecords(taskCtx plugin.SubTaskContext) errors.Error {
 		UrlTemplate:        "{{ .Params.OrganizationId }}/{{ .Params.ProjectId }}/_apis/build/builds/{{ .Input.AzuredevopsId }}/Timeline?api-version=7.1",
 		Query:              BuildPaginator(true),
 		ResponseParser:     ParseRawMessageFromRecords,
-		AfterResponse:      ignoreDeletedBuilds, // Ignore the 404 response if builds are deleted during the collection
+		AfterResponse:      ignoreInvalidTimelineResponse, // Skip builds with missing/malformed timelines (e.g. YAML syntax errors)
 	})
 	if err != nil {
 		return err
diff --git a/backend/plugins/azuredevops_go/tasks/shared.go b/backend/plugins/azuredevops_go/tasks/shared.go
index 3ceae7c98..e6fe0eca1 100644
--- a/backend/plugins/azuredevops_go/tasks/shared.go
+++ b/backend/plugins/azuredevops_go/tasks/shared.go
@@ -18,14 +18,17 @@ limitations under the License.
 package tasks
 
 import (
+	"bytes"
 	"encoding/json"
 	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+
 	"github.com/apache/incubator-devlake/core/errors"
 	"github.com/apache/incubator-devlake/core/models/domainlayer/devops"
 	"github.com/apache/incubator-devlake/core/plugin"
 	"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
-	"net/http"
-	"net/url"
 )
 
 // Build and TimeLine Record State and Result types can be found here:
@@ -145,9 +148,29 @@ func change203To401(res *http.Response) errors.Error {
 	return nil
 }
 
-func ignoreDeletedBuilds(res *http.Response) errors.Error {
+// ignoreInvalidTimelineResponse is an AfterResponse handler for the Timeline API.
+// It skips builds whose timeline response is missing or unparseable (e.g. builds
+// that failed due to a YAML syntax error never produce a usable timeline), instead
+// of aborting the entire subtask.
+func ignoreInvalidTimelineResponse(res *http.Response) errors.Error {
+	// Keep existing behaviour: treat 404 as a graceful skip (build was deleted).
 	if res.StatusCode == http.StatusNotFound {
 		return api.ErrIgnoreAndContinue
 	}
+
+	// Read the body so we can inspect it, then restore it for the ResponseParser.
+	body, err := io.ReadAll(res.Body)
+	_ = res.Body.Close()
+	if err != nil {
+		return api.ErrIgnoreAndContinue
+	}
+	res.Body = io.NopCloser(bytes.NewReader(body))
+
+	// An empty body or non-JSON body means the timeline is not available.
+	// Return ErrIgnoreAndContinue so the build is skipped without failing the subtask.
+	if len(body) == 0 || !json.Valid(body) {
+		return api.ErrIgnoreAndContinue
+	}
+
 	return nil
 }
diff --git a/backend/plugins/azuredevops_go/tasks/shared_test.go b/backend/plugins/azuredevops_go/tasks/shared_test.go
new file mode 100644
index 000000000..353ae3f4c
--- /dev/null
+++ b/backend/plugins/azuredevops_go/tasks/shared_test.go
@@ -0,0 +1,72 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package tasks
+
+import (
+	"bytes"
+	"io"
+	"net/http"
+	"testing"
+
+	"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+	"github.com/stretchr/testify/assert"
+)
+
+func makeResponse(statusCode int, body string) *http.Response {
+	return &http.Response{
+		StatusCode: statusCode,
+		Body:       io.NopCloser(bytes.NewBufferString(body)),
+		Request:    &http.Request{},
+	}
+}
+
+func TestIgnoreInvalidTimelineResponse_404(t *testing.T) {
+	res := makeResponse(http.StatusNotFound, "")
+	err := ignoreInvalidTimelineResponse(res)
+	assert.Equal(t, api.ErrIgnoreAndContinue, err, "404 should return ErrIgnoreAndContinue")
+}
+
+func TestIgnoreInvalidTimelineResponse_EmptyBody(t *testing.T) {
+	res := makeResponse(http.StatusOK, "")
+	err := ignoreInvalidTimelineResponse(res)
+	assert.Equal(t, api.ErrIgnoreAndContinue, err, "empty body should return ErrIgnoreAndContinue")
+}
+
+func TestIgnoreInvalidTimelineResponse_NonJSONBody(t *testing.T) {
+	res := makeResponse(http.StatusOK, "not json at all")
+	err := ignoreInvalidTimelineResponse(res)
+	assert.Equal(t, api.ErrIgnoreAndContinue, err, "non-JSON body should return ErrIgnoreAndContinue")
+}
+
+func TestIgnoreInvalidTimelineResponse_ValidJSON_ReturnsNil(t *testing.T) {
+	validJSON := `{"records":[{"id":"abc","type":"Job","name":"Build"}]}`
+	res := makeResponse(http.StatusOK, validJSON)
+	err := ignoreInvalidTimelineResponse(res)
+	assert.Nil(t, err, "valid JSON body should return nil")
+
+	// Verify body is still readable after the handler restored it.
+	remaining, readErr := io.ReadAll(res.Body)
+	assert.NoError(t, readErr)
+	assert.Equal(t, validJSON, string(remaining), "body should be restored for downstream parser")
+}
+
+func TestIgnoreInvalidTimelineResponse_ValidEmptyJSONObject(t *testing.T) {
+	res := makeResponse(http.StatusOK, "{}")
+	err := ignoreInvalidTimelineResponse(res)
+	assert.Nil(t, err, "valid empty JSON object should return nil")
+}