From 67a37efe59163aa375ba55ec5e5e52dd0c4e6229 Mon Sep 17 00:00:00 2001 From: Mohammad El-Naqa <55245971+MRNAQA@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:10:02 +0300 Subject: [PATCH] fix(github): leave lead time unset when an issue closes before creation GitHub can report closedAt before createdAt, for example for an issue that was closed and then transferred from another repository. Both GitHub issue extractors cast the negative duration to uint, which wraps to 18446744073709551572. The Convert Issues subtask then fails on every run with MySQL Error 1264 (out of range for issues.lead_time_minutes). Leave lead time unset in that case, as the Linear plugin already does. --- .../plugins/github/tasks/issue_extractor.go | 17 +++++++- .../github/tasks/issue_extractor_test.go | 42 +++++++++++++++++++ .../github_graphql/tasks/issue_extractor.go | 3 +- 3 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 backend/plugins/github/tasks/issue_extractor_test.go diff --git a/backend/plugins/github/tasks/issue_extractor.go b/backend/plugins/github/tasks/issue_extractor.go index 2f7771216f5..2f80c928617 100644 --- a/backend/plugins/github/tasks/issue_extractor.go +++ b/backend/plugins/github/tasks/issue_extractor.go @@ -21,6 +21,7 @@ import ( "encoding/json" "regexp" "strings" + "time" "github.com/apache/devlake/core/dal" "github.com/apache/devlake/core/errors" @@ -203,8 +204,7 @@ func convertGithubIssue(issue *IssuesResponse, connectionId uint64, repositoryId githubIssue.AuthorName = issue.User.Login } if issue.ClosedAt != nil { - temp := uint(issue.ClosedAt.ToTime().Sub(issue.GithubCreatedAt.ToTime()).Minutes()) - githubIssue.LeadTimeMinutes = &temp + githubIssue.LeadTimeMinutes = IssueLeadTimeMinutes(issue.GithubCreatedAt.ToTime(), issue.ClosedAt.ToTime()) } if issue.Milestone != nil { githubIssue.MilestoneId = issue.Milestone.Id @@ -212,6 +212,19 @@ func convertGithubIssue(issue *IssuesResponse, connectionId uint64, repositoryId return githubIssue, nil } +// IssueLeadTimeMinutes returns the minutes from creation to close. +// GitHub can report a close time before the creation time, for example for an +// issue that was closed and then transferred from another repository. A negative +// duration cast to uint wraps to a huge value that the domain issues table rejects, +// which fails the whole Convert Issues subtask, so leave lead time unset instead. +func IssueLeadTimeMinutes(createdAt, closedAt time.Time) *uint { + if closedAt.Before(createdAt) { + return nil + } + minutes := uint(closedAt.Sub(createdAt).Minutes()) + return &minutes +} + func convertGithubLabels(issueRegexes *IssueRegexes, issue *IssuesResponse, githubIssue *models.GithubIssue) ([]interface{}, errors.Error) { var results []interface{} var joinedLabels []string diff --git a/backend/plugins/github/tasks/issue_extractor_test.go b/backend/plugins/github/tasks/issue_extractor_test.go new file mode 100644 index 00000000000..b8294240ace --- /dev/null +++ b/backend/plugins/github/tasks/issue_extractor_test.go @@ -0,0 +1,42 @@ +/* +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 ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestIssueLeadTimeMinutes(t *testing.T) { + createdAt := time.Date(2026, 11, 1, 9, 49, 53, 0, time.UTC) + + leadTime := IssueLeadTimeMinutes(createdAt, createdAt.Add(90*time.Minute)) + if assert.NotNil(t, leadTime) { + assert.Equal(t, uint(90), *leadTime) + } + + leadTime = IssueLeadTimeMinutes(createdAt, createdAt) + if assert.NotNil(t, leadTime) { + assert.Equal(t, uint(0), *leadTime) + } + + // Closed before created, as GitHub reports for some transferred issues. + assert.Nil(t, IssueLeadTimeMinutes(createdAt, createdAt.Add(-44*time.Minute))) +} diff --git a/backend/plugins/github_graphql/tasks/issue_extractor.go b/backend/plugins/github_graphql/tasks/issue_extractor.go index 345b6fcfb59..3f15bd0d949 100644 --- a/backend/plugins/github_graphql/tasks/issue_extractor.go +++ b/backend/plugins/github_graphql/tasks/issue_extractor.go @@ -152,8 +152,7 @@ func convertGithubIssue(milestoneMap map[int]int, issue *GraphqlQueryIssue, conn githubIssue.AuthorName = author.Login } if issue.ClosedAt != nil { - temp := uint(issue.ClosedAt.Sub(issue.CreatedAt).Minutes()) - githubIssue.LeadTimeMinutes = &temp + githubIssue.LeadTimeMinutes = githubTasks.IssueLeadTimeMinutes(issue.CreatedAt, *issue.ClosedAt) } if issue.Milestone != nil { if milestoneId, ok := milestoneMap[issue.Milestone.Number]; ok {