Skip to content

Commit c1c1285

Browse files
authored
fix(github-graphql): fix empty startedAt field (#8536)
GitHub GraphQL API can return check runs with nil or a zero `StartedAt` (manual testing over GitHub API returns a zero `time.Time` value -0001-01-01-) time for jobs that were skipped immediately. These jobs have a CompletedAt time (when they were skipped) but no actual start time resulting to the extractor crashing.
1 parent 7b78750 commit c1c1285

2 files changed

Lines changed: 339 additions & 5 deletions

File tree

backend/plugins/github_graphql/tasks/job_collector.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package tasks
1919

2020
import (
2121
"encoding/json"
22+
"fmt"
2223
"reflect"
2324
"time"
2425

@@ -69,11 +70,11 @@ type GraphqlQueryCheckRun struct {
6970
StartedAt *time.Time
7071
Conclusion string
7172
CompletedAt *time.Time
72-
//ExternalId string
73-
//Url string
74-
//Title interface{}
75-
//Text interface{}
76-
//Summary interface{}
73+
// ExternalId string
74+
// Url string
75+
// Title interface{}
76+
// Text interface{}
77+
// Summary interface{}
7778

7879
Steps struct {
7980
TotalCount int
@@ -201,6 +202,14 @@ func CollectJobs(taskCtx plugin.SubTaskContext) errors.Error {
201202
RunId: runId,
202203
GraphqlQueryCheckRun: &checkRun,
203204
}
205+
// A checkRun without a startedAt time is a run that was never started (skipped)
206+
// akwardly, GitHub assigns a completedAt time to such runs, which is the time when the run was skipped
207+
// TODO: Decide if we want to skip those runs or should we assign the startedAt time to the completedAt time
208+
if dbCheckRun.StartedAt == nil || dbCheckRun.StartedAt.IsZero() {
209+
debug := fmt.Sprintf("collector: checkRun.StartedAt is nil or zero: %s", dbCheckRun.Id)
210+
taskCtx.GetLogger().Debug(debug, "Collector: CheckRun started at is nil or zero")
211+
continue
212+
}
204213
updatedAt := dbCheckRun.StartedAt
205214
if dbCheckRun.CompletedAt != nil {
206215
updatedAt = dbCheckRun.CompletedAt
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
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+
"encoding/json"
22+
"testing"
23+
"time"
24+
25+
"github.com/apache/incubator-devlake/core/errors"
26+
"github.com/stretchr/testify/assert"
27+
)
28+
29+
type MockLogger struct {
30+
debugCalls []string
31+
}
32+
33+
func (m *MockLogger) Debug(args ...interface{}) {
34+
if len(args) >= 1 {
35+
if str, ok := args[0].(string); ok {
36+
m.debugCalls = append(m.debugCalls, str)
37+
}
38+
}
39+
}
40+
41+
func (m *MockLogger) Info(args ...interface{}) {}
42+
func (m *MockLogger) Warn(args ...interface{}) {}
43+
func (m *MockLogger) Error(args ...interface{}) {}
44+
func (m *MockLogger) Fatal(args ...interface{}) {}
45+
func (m *MockLogger) Panic(args ...interface{}) {}
46+
func (m *MockLogger) Nested(name string) interface{} { return m }
47+
48+
// MockTaskContext implements a simple task context for testing
49+
type MockTaskContext struct {
50+
logger *MockLogger
51+
}
52+
53+
func (m *MockTaskContext) GetLogger() interface{} {
54+
return m.logger
55+
}
56+
57+
func TestJobCollector_ResponseParser_SkipsJobsWithNilStartedAt(t *testing.T) {
58+
// Create a mock task context
59+
mockLogger := &MockLogger{}
60+
mockTaskCtx := &MockTaskContext{logger: mockLogger}
61+
62+
now := time.Now()
63+
64+
// Check run with zero StartedAt should be skipped
65+
checkRunSkipped := GraphqlQueryCheckRun{
66+
Id: "skipped-job-1",
67+
Name: "Skipped Job",
68+
DatabaseId: 456,
69+
Status: "completed",
70+
Conclusion: "skipped",
71+
StartedAt: nil,
72+
CompletedAt: &now,
73+
}
74+
75+
checkRunNormal := GraphqlQueryCheckRun{
76+
Id: "normal-job-1",
77+
Name: "Normal Job",
78+
DatabaseId: 789,
79+
Status: "completed",
80+
Conclusion: "success",
81+
StartedAt: &now,
82+
CompletedAt: &now,
83+
}
84+
85+
checkRuns := []GraphqlQueryCheckRun{checkRunSkipped, checkRunNormal}
86+
87+
responseParser := func(checkRuns []GraphqlQueryCheckRun, runId int) (messages []json.RawMessage, err errors.Error) {
88+
for _, checkRun := range checkRuns {
89+
dbCheckRun := &DbCheckRun{
90+
RunId: runId,
91+
GraphqlQueryCheckRun: &checkRun,
92+
}
93+
if dbCheckRun.StartedAt == nil || dbCheckRun.StartedAt.IsZero() {
94+
mockTaskCtx.GetLogger().(*MockLogger).Debug("collector: checkRun.StartedAt is nil or zero: " + dbCheckRun.Id)
95+
continue
96+
}
97+
messages = append(messages, errors.Must1(json.Marshal(dbCheckRun)))
98+
}
99+
return
100+
}
101+
102+
// Execute the response parser
103+
messages, err := responseParser(checkRuns, 123)
104+
105+
// Verify results
106+
assert.Nil(t, err)
107+
assert.Len(t, messages, 1, "Should only process jobs with valid StartedAt")
108+
109+
// Verify the processed message is the correct job
110+
var processedJob DbCheckRun
111+
unmarshalErr := json.Unmarshal(messages[0], &processedJob)
112+
assert.Nil(t, unmarshalErr)
113+
assert.Equal(t, "normal-job-1", processedJob.Id)
114+
assert.Equal(t, "Normal Job", processedJob.Name)
115+
assert.NotNil(t, processedJob.StartedAt)
116+
117+
assert.Len(t, mockLogger.debugCalls, 1)
118+
assert.Contains(t, mockLogger.debugCalls[0], "skipped-job-1")
119+
}
120+
121+
func TestJobCollector_ResponseParser_SkipsJobsWithZeroStartedAt(t *testing.T) {
122+
mockLogger := &MockLogger{}
123+
mockTaskCtx := &MockTaskContext{logger: mockLogger}
124+
125+
// Create test data with a job that has zero StartedAt
126+
now := time.Now()
127+
zeroTime := time.Time{} // Zero time
128+
129+
// Check run with zero StartedAt should be skipped
130+
checkRunZero := GraphqlQueryCheckRun{
131+
Id: "zero-time-job-1",
132+
Name: "Zero Time Job",
133+
DatabaseId: 456,
134+
Status: "completed",
135+
Conclusion: "skipped",
136+
StartedAt: &zeroTime,
137+
CompletedAt: &now,
138+
}
139+
140+
// Check run with valid StartedAt should be processed
141+
checkRunNormal := GraphqlQueryCheckRun{
142+
Id: "normal-job-1",
143+
Name: "Normal Job",
144+
DatabaseId: 789,
145+
Status: "completed",
146+
Conclusion: "success",
147+
StartedAt: &now,
148+
CompletedAt: &now,
149+
}
150+
151+
checkRuns := []GraphqlQueryCheckRun{checkRunZero, checkRunNormal}
152+
153+
responseParser := func(checkRuns []GraphqlQueryCheckRun, runId int) (messages []json.RawMessage, err errors.Error) {
154+
for _, checkRun := range checkRuns {
155+
dbCheckRun := &DbCheckRun{
156+
RunId: runId,
157+
GraphqlQueryCheckRun: &checkRun,
158+
}
159+
if dbCheckRun.StartedAt == nil || dbCheckRun.StartedAt.IsZero() {
160+
mockTaskCtx.GetLogger().(*MockLogger).Debug("collector: checkRun.StartedAt is nil or zero: " + dbCheckRun.Id)
161+
continue
162+
}
163+
messages = append(messages, errors.Must1(json.Marshal(dbCheckRun)))
164+
}
165+
return
166+
}
167+
168+
// Execute the response parser
169+
messages, err := responseParser(checkRuns, 123)
170+
171+
// Verify results
172+
assert.Nil(t, err)
173+
assert.Len(t, messages, 1, "Should only process jobs with valid StartedAt")
174+
175+
// Verify the processed message is the correct job
176+
var processedJob DbCheckRun
177+
unmarshalErr := json.Unmarshal(messages[0], &processedJob)
178+
assert.Nil(t, unmarshalErr)
179+
assert.Equal(t, "normal-job-1", processedJob.Id)
180+
assert.Equal(t, "Normal Job", processedJob.Name)
181+
assert.NotNil(t, processedJob.StartedAt)
182+
assert.False(t, processedJob.StartedAt.IsZero())
183+
184+
assert.Len(t, mockLogger.debugCalls, 1)
185+
assert.Contains(t, mockLogger.debugCalls[0], "zero-time-job-1")
186+
}
187+
188+
func TestJobCollector_ResponseParser_ProcessesValidJobs(t *testing.T) {
189+
// Create a mock task context
190+
mockLogger := &MockLogger{}
191+
mockTaskCtx := &MockTaskContext{logger: mockLogger}
192+
193+
// Create test data with valid jobs
194+
now := time.Now()
195+
earlier := now.Add(-time.Hour)
196+
197+
// Check run with zero StartedAt should be skipped
198+
checkRun1 := GraphqlQueryCheckRun{
199+
Id: "job-1",
200+
Name: "Job 1",
201+
DatabaseId: 456,
202+
Status: "completed",
203+
Conclusion: "success",
204+
StartedAt: &earlier,
205+
CompletedAt: &now,
206+
}
207+
208+
// Check run with valid StartedAt should be processed
209+
checkRun2 := GraphqlQueryCheckRun{
210+
Id: "job-2",
211+
Name: "Job 2",
212+
DatabaseId: 789,
213+
Status: "in_progress",
214+
Conclusion: "",
215+
StartedAt: &now,
216+
CompletedAt: nil,
217+
}
218+
219+
checkRuns := []GraphqlQueryCheckRun{checkRun1, checkRun2}
220+
221+
responseParser := func(checkRuns []GraphqlQueryCheckRun, runId int) (messages []json.RawMessage, err errors.Error) {
222+
for _, checkRun := range checkRuns {
223+
dbCheckRun := &DbCheckRun{
224+
RunId: runId,
225+
GraphqlQueryCheckRun: &checkRun,
226+
}
227+
if dbCheckRun.StartedAt == nil || dbCheckRun.StartedAt.IsZero() {
228+
mockTaskCtx.GetLogger().(*MockLogger).Debug("collector: checkRun.StartedAt is nil or zero: " + dbCheckRun.Id)
229+
continue
230+
}
231+
messages = append(messages, errors.Must1(json.Marshal(dbCheckRun)))
232+
}
233+
return
234+
}
235+
236+
// Execute the response parser
237+
messages, err := responseParser(checkRuns, 123)
238+
239+
assert.Nil(t, err)
240+
assert.Len(t, messages, 2, "Should process both valid jobs")
241+
242+
// Verify both jobs were processed
243+
var job1, job2 DbCheckRun
244+
unmarshalErr1 := json.Unmarshal(messages[0], &job1)
245+
unmarshalErr2 := json.Unmarshal(messages[1], &job2)
246+
assert.Nil(t, unmarshalErr1)
247+
assert.Nil(t, unmarshalErr2)
248+
249+
assert.Equal(t, "job-1", job1.Id)
250+
assert.Equal(t, "Job 1", job1.Name)
251+
assert.Equal(t, 123, job1.RunId)
252+
assert.NotNil(t, job1.StartedAt)
253+
254+
assert.Equal(t, "job-2", job2.Id)
255+
assert.Equal(t, "Job 2", job2.Name)
256+
assert.Equal(t, 123, job2.RunId)
257+
assert.NotNil(t, job2.StartedAt)
258+
259+
assert.Len(t, mockLogger.debugCalls, 0)
260+
}
261+
262+
func TestDbCheckRun_StartedAtValidation(t *testing.T) {
263+
now := time.Now()
264+
zeroTime := time.Time{}
265+
266+
testCases := []struct {
267+
name string
268+
checkRun *DbCheckRun
269+
expectNil bool
270+
expectZero bool
271+
}{
272+
{
273+
name: "nil StartedAt",
274+
checkRun: &DbCheckRun{
275+
RunId: 123,
276+
GraphqlQueryCheckRun: &GraphqlQueryCheckRun{
277+
Id: "test-1",
278+
StartedAt: nil,
279+
},
280+
},
281+
expectNil: true,
282+
expectZero: false,
283+
},
284+
{
285+
name: "zero StartedAt",
286+
checkRun: &DbCheckRun{
287+
RunId: 123,
288+
GraphqlQueryCheckRun: &GraphqlQueryCheckRun{
289+
Id: "test-2",
290+
StartedAt: &zeroTime,
291+
},
292+
},
293+
expectNil: false,
294+
expectZero: true,
295+
},
296+
{
297+
name: "valid StartedAt",
298+
checkRun: &DbCheckRun{
299+
RunId: 123,
300+
GraphqlQueryCheckRun: &GraphqlQueryCheckRun{
301+
Id: "test-3",
302+
StartedAt: &now,
303+
},
304+
},
305+
expectNil: false,
306+
expectZero: false,
307+
},
308+
}
309+
310+
for _, tc := range testCases {
311+
t.Run(tc.name, func(t *testing.T) {
312+
if tc.expectNil {
313+
assert.True(t, tc.checkRun.StartedAt == nil, "StartedAt should be nil")
314+
} else {
315+
assert.False(t, tc.checkRun.StartedAt == nil, "StartedAt should not be nil")
316+
}
317+
318+
if tc.expectZero && tc.checkRun.StartedAt != nil {
319+
assert.True(t, tc.checkRun.StartedAt.IsZero(), "StartedAt should be zero")
320+
} else if !tc.expectNil {
321+
assert.False(t, tc.checkRun.StartedAt.IsZero(), "StartedAt should not be zero")
322+
}
323+
})
324+
}
325+
}

0 commit comments

Comments
 (0)