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 {