Skip to content

Commit 3472cc3

Browse files
authored
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 <cornelius.schuchardt@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 <cornelius.schuchardt@beck.de>
1 parent 16c5b92 commit 3472cc3

5 files changed

Lines changed: 103 additions & 8 deletions

File tree

backend/core/migration/linter/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ func firstLineFromFile(path string) string {
5151
for scanner.Scan() {
5252
return scanner.Text()
5353
}
54-
panic(fmt.Errorf("empty file: " + path))
54+
panic(fmt.Errorf("empty file: %s", path))
5555
}
5656

5757
const (

backend/impls/logruslog/logger.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,11 @@ func (l *DefaultLogger) Info(format string, a ...interface{}) {
7272
}
7373

7474
func (l *DefaultLogger) Warn(err error, format string, a ...interface{}) {
75-
l.Log(log.LOG_WARN, formatMessage(err, format, a...))
75+
l.Log(log.LOG_WARN, "%s", formatMessage(err, format, a...))
7676
}
7777

7878
func (l *DefaultLogger) Error(err error, format string, a ...interface{}) {
79-
l.Log(log.LOG_ERROR, formatMessage(err, format, a...))
79+
l.Log(log.LOG_ERROR, "%s", formatMessage(err, format, a...))
8080
}
8181

8282
func (l *DefaultLogger) SetStream(config *log.LoggerStreamConfig) {

backend/plugins/azuredevops_go/tasks/ci_cd_timeline_records_collector.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func CollectRecords(taskCtx plugin.SubTaskContext) errors.Error {
4949
cursor, err := db.Cursor(
5050
dal.Select("azuredevops_id"),
5151
dal.From(models.AzuredevopsBuild{}.TableName()),
52-
dal.Where("repository_id = ? and connection_id=? and result != ?", data.Options.RepositoryId, data.Options.ConnectionId, "failed"),
52+
dal.Where("repository_id = ? and connection_id=? and result NOT IN ?", data.Options.RepositoryId, data.Options.ConnectionId, []string{"failed", "none"}),
5353
)
5454
if err != nil {
5555
return err
@@ -68,7 +68,7 @@ func CollectRecords(taskCtx plugin.SubTaskContext) errors.Error {
6868
UrlTemplate: "{{ .Params.OrganizationId }}/{{ .Params.ProjectId }}/_apis/build/builds/{{ .Input.AzuredevopsId }}/Timeline?api-version=7.1",
6969
Query: BuildPaginator(true),
7070
ResponseParser: ParseRawMessageFromRecords,
71-
AfterResponse: ignoreDeletedBuilds, // Ignore the 404 response if builds are deleted during the collection
71+
AfterResponse: ignoreInvalidTimelineResponse, // Skip builds with missing/malformed timelines (e.g. YAML syntax errors)
7272
})
7373
if err != nil {
7474
return err

backend/plugins/azuredevops_go/tasks/shared.go

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,17 @@ limitations under the License.
1818
package tasks
1919

2020
import (
21+
"bytes"
2122
"encoding/json"
2223
"fmt"
24+
"io"
25+
"net/http"
26+
"net/url"
27+
2328
"github.com/apache/incubator-devlake/core/errors"
2429
"github.com/apache/incubator-devlake/core/models/domainlayer/devops"
2530
"github.com/apache/incubator-devlake/core/plugin"
2631
"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
27-
"net/http"
28-
"net/url"
2932
)
3033

3134
// Build and TimeLine Record State and Result types can be found here:
@@ -145,9 +148,29 @@ func change203To401(res *http.Response) errors.Error {
145148
return nil
146149
}
147150

148-
func ignoreDeletedBuilds(res *http.Response) errors.Error {
151+
// ignoreInvalidTimelineResponse is an AfterResponse handler for the Timeline API.
152+
// It skips builds whose timeline response is missing or unparseable (e.g. builds
153+
// that failed due to a YAML syntax error never produce a usable timeline), instead
154+
// of aborting the entire subtask.
155+
func ignoreInvalidTimelineResponse(res *http.Response) errors.Error {
156+
// Keep existing behaviour: treat 404 as a graceful skip (build was deleted).
149157
if res.StatusCode == http.StatusNotFound {
150158
return api.ErrIgnoreAndContinue
151159
}
160+
161+
// Read the body so we can inspect it, then restore it for the ResponseParser.
162+
body, err := io.ReadAll(res.Body)
163+
_ = res.Body.Close()
164+
if err != nil {
165+
return api.ErrIgnoreAndContinue
166+
}
167+
res.Body = io.NopCloser(bytes.NewReader(body))
168+
169+
// An empty body or non-JSON body means the timeline is not available.
170+
// Return ErrIgnoreAndContinue so the build is skipped without failing the subtask.
171+
if len(body) == 0 || !json.Valid(body) {
172+
return api.ErrIgnoreAndContinue
173+
}
174+
152175
return nil
153176
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package tasks
19+
20+
import (
21+
"bytes"
22+
"io"
23+
"net/http"
24+
"testing"
25+
26+
"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
27+
"github.com/stretchr/testify/assert"
28+
)
29+
30+
func makeResponse(statusCode int, body string) *http.Response {
31+
return &http.Response{
32+
StatusCode: statusCode,
33+
Body: io.NopCloser(bytes.NewBufferString(body)),
34+
Request: &http.Request{},
35+
}
36+
}
37+
38+
func TestIgnoreInvalidTimelineResponse_404(t *testing.T) {
39+
res := makeResponse(http.StatusNotFound, "")
40+
err := ignoreInvalidTimelineResponse(res)
41+
assert.Equal(t, api.ErrIgnoreAndContinue, err, "404 should return ErrIgnoreAndContinue")
42+
}
43+
44+
func TestIgnoreInvalidTimelineResponse_EmptyBody(t *testing.T) {
45+
res := makeResponse(http.StatusOK, "")
46+
err := ignoreInvalidTimelineResponse(res)
47+
assert.Equal(t, api.ErrIgnoreAndContinue, err, "empty body should return ErrIgnoreAndContinue")
48+
}
49+
50+
func TestIgnoreInvalidTimelineResponse_NonJSONBody(t *testing.T) {
51+
res := makeResponse(http.StatusOK, "not json at all")
52+
err := ignoreInvalidTimelineResponse(res)
53+
assert.Equal(t, api.ErrIgnoreAndContinue, err, "non-JSON body should return ErrIgnoreAndContinue")
54+
}
55+
56+
func TestIgnoreInvalidTimelineResponse_ValidJSON_ReturnsNil(t *testing.T) {
57+
validJSON := `{"records":[{"id":"abc","type":"Job","name":"Build"}]}`
58+
res := makeResponse(http.StatusOK, validJSON)
59+
err := ignoreInvalidTimelineResponse(res)
60+
assert.Nil(t, err, "valid JSON body should return nil")
61+
62+
// Verify body is still readable after the handler restored it.
63+
remaining, readErr := io.ReadAll(res.Body)
64+
assert.NoError(t, readErr)
65+
assert.Equal(t, validJSON, string(remaining), "body should be restored for downstream parser")
66+
}
67+
68+
func TestIgnoreInvalidTimelineResponse_ValidEmptyJSONObject(t *testing.T) {
69+
res := makeResponse(http.StatusOK, "{}")
70+
err := ignoreInvalidTimelineResponse(res)
71+
assert.Nil(t, err, "valid empty JSON object should return nil")
72+
}

0 commit comments

Comments
 (0)