Skip to content

Commit e3a4271

Browse files
feat(jira): add JIRA_SKIP_UNPARSEABLE_ISSUES to survive unparseable issue pages (#9026)
* feat(jira): add JIRA_SKIP_UNPARSEABLE_ISSUES to survive unparseable issue pages A page that comes back with a successful status but a body that is not the expected JSON - a truncated payload, or an HTML error page substituted by a proxy - fails the whole collectIssues subtask. On a large Jira instance that discards an otherwise complete sync because of a single bad page, typically during deep pagination with changelog expansion. Add JIRA_SKIP_UNPARSEABLE_ISSUES, read through the same taskCtx.GetConfigReader().GetBool() pattern that JIRA_JQL_AUTO_FULL_REFRESH already uses in this plugin. It defaults to false, preserving the current behaviour of surfacing the error and failing the subtask. When enabled, the offending page is logged with its URL and body size and skipped, and collection continues. Only parse failures on otherwise-successful responses are affected. Non-2xx statuses never reach the response parser and keep their existing retry behaviour. The V2 (api/2/search) and V3 (api/3/search/jql) response parsers were byte identical, so both now share a single parseIssuesResponse helper rather than carrying two copies of the change. A skipped page yields an empty non-nil slice, so the collector records a page with no rows instead of treating the result as absent. Signed-off-by: Bhanu Chander Vallabaneni <v.bhanuchander@gmail.com> * docs(jira): document JIRA_SKIP_UNPARSEABLE_ISSUES in env.example Requested in review on #9026. Signed-off-by: Bhanu Chander Vallabaneni <v.bhanuchander@gmail.com> --------- Signed-off-by: Bhanu Chander Vallabaneni <v.bhanuchander@gmail.com>
1 parent 14a4e5b commit e3a4271

3 files changed

Lines changed: 178 additions & 28 deletions

File tree

backend/plugins/jira/tasks/issue_collector.go

Lines changed: 50 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030

3131
"github.com/apache/incubator-devlake/core/dal"
3232
"github.com/apache/incubator-devlake/core/errors"
33+
"github.com/apache/incubator-devlake/core/log"
3334
"github.com/apache/incubator-devlake/core/plugin"
3435
"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
3536
"github.com/apache/incubator-devlake/plugins/jira/models"
@@ -95,12 +96,17 @@ func CollectIssues(taskCtx plugin.SubTaskContext) errors.Error {
9596
pageSize = 100
9697
}
9798

99+
skipUnparseable := taskCtx.GetConfigReader().GetBool("JIRA_SKIP_UNPARSEABLE_ISSUES")
100+
if skipUnparseable {
101+
logger.Info("JIRA_SKIP_UNPARSEABLE_ISSUES is enabled, unparseable issue pages will be skipped")
102+
}
103+
98104
if strings.EqualFold(string(data.JiraServerInfo.DeploymentType), string(models.DeploymentServer)) {
99105
logger.Info("Using api/2/search for JIRA Server issue collection")
100-
err = setupIssueV2Collector(apiCollector, data, filterJql, pageSize)
106+
err = setupIssueV2Collector(apiCollector, data, filterJql, pageSize, skipUnparseable, logger)
101107
} else {
102108
logger.Info("Using api/3/search/jql for JIRA Cloud issue collection")
103-
err = setupIssueV3Collector(apiCollector, data, filterJql, pageSize)
109+
err = setupIssueV3Collector(apiCollector, data, filterJql, pageSize, skipUnparseable, logger)
104110
}
105111
if err != nil {
106112
return err
@@ -176,7 +182,45 @@ func buildFilterJQL(filterId string, extraJql string, incrementalJql string) str
176182
return strings.Join(conditions, " AND ") + " " + orderBy
177183
}
178184

179-
func setupIssueV2Collector(apiCollector *api.StatefulApiCollector, data *JiraTaskData, filterJql string, pageSize int) errors.Error {
185+
// parseIssuesResponse extracts the `issues` array from a Jira search response.
186+
//
187+
// A response that arrived with a successful status but carries a body which is not the
188+
// expected JSON - a truncated payload, or an HTML error page substituted by a proxy -
189+
// normally fails the whole collectIssues subtask, discarding an otherwise complete sync
190+
// because of a single page. When skipUnparseable is set the page is logged and skipped
191+
// instead. Non-2xx responses never reach this point; they are handled by the retry logic
192+
// in the API client.
193+
func parseIssuesResponse(res *http.Response, skipUnparseable bool, logger log.Logger) ([]json.RawMessage, errors.Error) {
194+
blob, err := io.ReadAll(res.Body)
195+
if err != nil {
196+
return nil, errors.Convert(err)
197+
}
198+
var body struct {
199+
Issues []json.RawMessage `json:"issues"`
200+
}
201+
if err := json.Unmarshal(blob, &body); err != nil {
202+
if !skipUnparseable {
203+
return nil, errors.Convert(err)
204+
}
205+
if logger != nil {
206+
logger.Warn(err, "skipping unparseable issue page from %s (%d bytes)", responseUrl(res), len(blob))
207+
}
208+
return []json.RawMessage{}, nil
209+
}
210+
return body.Issues, nil
211+
}
212+
213+
// responseUrl reports the request URL behind a response, for log messages. The request is
214+
// always populated on responses returned by the API client, but a hand-built response in a
215+
// test may not carry one.
216+
func responseUrl(res *http.Response) string {
217+
if res == nil || res.Request == nil || res.Request.URL == nil {
218+
return "unknown url"
219+
}
220+
return res.Request.URL.String()
221+
}
222+
223+
func setupIssueV2Collector(apiCollector *api.StatefulApiCollector, data *JiraTaskData, filterJql string, pageSize int, skipUnparseable bool, logger log.Logger) errors.Error {
180224
return apiCollector.InitCollector(api.ApiCollectorArgs{
181225
ApiClient: data.ApiClient,
182226
PageSize: pageSize,
@@ -192,23 +236,12 @@ func setupIssueV2Collector(apiCollector *api.StatefulApiCollector, data *JiraTas
192236
GetTotalPages: GetTotalPagesFromResponse,
193237
Concurrency: 10,
194238
ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) {
195-
var data struct {
196-
Issues []json.RawMessage `json:"issues"`
197-
}
198-
blob, err := io.ReadAll(res.Body)
199-
if err != nil {
200-
return nil, errors.Convert(err)
201-
}
202-
err = json.Unmarshal(blob, &data)
203-
if err != nil {
204-
return nil, errors.Convert(err)
205-
}
206-
return data.Issues, nil
239+
return parseIssuesResponse(res, skipUnparseable, logger)
207240
},
208241
})
209242
}
210243

211-
func setupIssueV3Collector(apiCollector *api.StatefulApiCollector, data *JiraTaskData, filterJql string, pageSize int) errors.Error {
244+
func setupIssueV3Collector(apiCollector *api.StatefulApiCollector, data *JiraTaskData, filterJql string, pageSize int, skipUnparseable bool, logger log.Logger) errors.Error {
212245
return apiCollector.InitCollector(api.ApiCollectorArgs{
213246
ApiClient: data.ApiClient,
214247
PageSize: pageSize,
@@ -226,18 +259,7 @@ func setupIssueV3Collector(apiCollector *api.StatefulApiCollector, data *JiraTas
226259
return query, nil
227260
},
228261
ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) {
229-
var data struct {
230-
Issues []json.RawMessage `json:"issues"`
231-
}
232-
blob, err := io.ReadAll(res.Body)
233-
if err != nil {
234-
return nil, errors.Convert(err)
235-
}
236-
err = json.Unmarshal(blob, &data)
237-
if err != nil {
238-
return nil, errors.Convert(err)
239-
}
240-
return data.Issues, nil
262+
return parseIssuesResponse(res, skipUnparseable, logger)
241263
},
242264
})
243265
}

backend/plugins/jira/tasks/issue_collector_test.go

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

2020
import (
21+
"io"
22+
"net/http"
23+
"net/url"
24+
"strings"
2125
"testing"
2226
"time"
2327

28+
"github.com/apache/incubator-devlake/helpers/unithelper"
29+
mocklog "github.com/apache/incubator-devlake/mocks/core/log"
2430
"github.com/apache/incubator-devlake/plugins/jira/models"
31+
"github.com/stretchr/testify/mock"
2532
)
2633

2734
func Test_buildJQL(t *testing.T) {
@@ -142,6 +149,117 @@ func Test_buildFilterJQL(t *testing.T) {
142149
}
143150
}
144151

152+
func Test_parseIssuesResponse(t *testing.T) {
153+
const twoIssues = `{"issues":[{"id":"1"},{"id":"2"}]}`
154+
// A proxy returning an HTML error page under a 200, the case reported in #8949.
155+
const htmlPage = `<html><head><title>502 Bad Gateway</title></head></html>`
156+
// A response truncated mid-flight, so the JSON never terminates.
157+
const truncated = `{"issues":[{"id":"1"},{"id":`
158+
159+
makeResponse := func(body string) *http.Response {
160+
return &http.Response{
161+
Body: io.NopCloser(strings.NewReader(body)),
162+
Request: &http.Request{URL: &url.URL{Scheme: "https", Host: "jira.example.com", Path: "/rest/api/2/search"}},
163+
}
164+
}
165+
166+
tests := []struct {
167+
name string
168+
body string
169+
skipUnparseable bool
170+
wantCount int
171+
wantErr bool
172+
// wantSkipped marks the cases that took the skip path, which must yield an
173+
// empty non-nil slice so the collector records "this page had no rows" rather
174+
// than treating the result as absent.
175+
wantSkipped bool
176+
}{
177+
{
178+
name: "valid page is parsed",
179+
body: twoIssues,
180+
wantCount: 2,
181+
},
182+
{
183+
name: "valid page is parsed when skipping is enabled",
184+
body: twoIssues,
185+
skipUnparseable: true,
186+
wantCount: 2,
187+
},
188+
{
189+
name: "html body fails by default",
190+
body: htmlPage,
191+
wantErr: true,
192+
},
193+
{
194+
name: "html body is skipped when enabled",
195+
body: htmlPage,
196+
skipUnparseable: true,
197+
wantCount: 0,
198+
wantSkipped: true,
199+
},
200+
{
201+
name: "truncated body fails by default",
202+
body: truncated,
203+
wantErr: true,
204+
},
205+
{
206+
name: "truncated body is skipped when enabled",
207+
body: truncated,
208+
skipUnparseable: true,
209+
wantCount: 0,
210+
wantSkipped: true,
211+
},
212+
{
213+
name: "valid page without an issues key yields no issues",
214+
body: `{"total":0}`,
215+
wantCount: 0,
216+
},
217+
}
218+
219+
// unithelper.DummyLogger stubs Warn with two arguments, but the real signature is
220+
// Warn(err, format, a ...interface{}), which mockery records as three. Add the
221+
// variadic form so the skip path can log.
222+
newLogger := func() *mocklog.Logger {
223+
logger := unithelper.DummyLogger()
224+
logger.On("Warn", mock.Anything, mock.Anything, mock.Anything).Maybe()
225+
return logger
226+
}
227+
228+
for _, tt := range tests {
229+
t.Run(tt.name, func(t *testing.T) {
230+
got, err := parseIssuesResponse(makeResponse(tt.body), tt.skipUnparseable, newLogger())
231+
if (err != nil) != tt.wantErr {
232+
t.Errorf("parseIssuesResponse() error = %v, wantErr %v", err, tt.wantErr)
233+
return
234+
}
235+
if tt.wantErr {
236+
return
237+
}
238+
if len(got) != tt.wantCount {
239+
t.Errorf("parseIssuesResponse() returned %d issues, want %d", len(got), tt.wantCount)
240+
}
241+
if tt.wantSkipped && got == nil {
242+
t.Error("parseIssuesResponse() returned nil for a skipped page, want an empty slice")
243+
}
244+
})
245+
}
246+
}
247+
248+
func Test_responseUrl(t *testing.T) {
249+
withUrl := &http.Response{
250+
Request: &http.Request{URL: &url.URL{Scheme: "https", Host: "jira.example.com", Path: "/rest/api/2/search"}},
251+
}
252+
if got, want := responseUrl(withUrl), "https://jira.example.com/rest/api/2/search"; got != want {
253+
t.Errorf("responseUrl() = %v, want %v", got, want)
254+
}
255+
if got, want := responseUrl(&http.Response{}), "unknown url"; got != want {
256+
t.Errorf("responseUrl() with no request = %v, want %v", got, want)
257+
}
258+
if got, want := responseUrl(nil), "unknown url"; got != want {
259+
t.Errorf("responseUrl(nil) = %v, want %v", got, want)
260+
}
261+
}
262+
145263
func Test_renderExtraJQL(t *testing.T) {
146264
makeData := func(boardId uint64, boardName string, _ string) *JiraTaskData {
147265
return &JiraTaskData{

env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,16 @@ USE_GO_GIT_IN_GIT_EXTRACTOR=false
8787
SKIP_COMMIT_STAT=false
8888
SKIP_COMMIT_FILES=true
8989

90+
# In plugin jira, continue collecting when a page of issues cannot be parsed.
91+
# A page that returns a successful HTTP status but a body that is not the
92+
# expected JSON - a truncated payload, or an HTML error page substituted by a
93+
# proxy - normally fails the whole collectIssues subtask, discarding an
94+
# otherwise complete sync because of a single page. Set to true to log and skip
95+
# that page instead. Only parse failures on successful responses are affected;
96+
# HTTP error statuses keep their existing retry behaviour.
97+
##########################
98+
JIRA_SKIP_UNPARSEABLE_ISSUES=false
99+
90100
# Set if response error when requesting /connections/{connection_id}/test should be wrapped or not
91101
##########################
92102
WRAP_RESPONSE_ERROR=

0 commit comments

Comments
 (0)