blob: 76908380607f058f8cc48a1f41db842f16d2092d [file]
/*
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 (
"encoding/json"
"reflect"
"time"
"github.com/apache/devlake/core/dal"
"github.com/apache/devlake/core/errors"
"github.com/apache/devlake/core/plugin"
"github.com/apache/devlake/core/utils"
helper "github.com/apache/devlake/helpers/pluginhelper/api"
"github.com/apache/devlake/plugins/github/models"
githubTasks "github.com/apache/devlake/plugins/github/tasks"
"github.com/merico-ai/graphql"
)
const RAW_GRAPHQL_JOBS_TABLE = "github_graphql_jobs"
// Collection mode configuration
const (
JOB_COLLECTION_MODE_BATCHING = "BATCHING"
JOB_COLLECTION_MODE_PAGINATING = "PAGINATING"
)
// Set the collection mode here
// BATCHING: Query multiple runs at once, no pagination (may miss jobs if >20 per run)
// PAGINATING: Query one run at a time with full pagination (complete data, more API calls)
const DEFAULT_JOB_COLLECTION_MODE = JOB_COLLECTION_MODE_BATCHING
// Mode-specific configuration
const (
DEFAULT_BATCHING_INPUT_STEP = 10 // Number of runs per request in BATCHING mode (must be > 1)
DEFAULT_BATCHING_PAGE_SIZE = 20 // Jobs per run in BATCHING mode (no pagination)
PAGINATING_INPUT_STEP = 1 // Number of runs per request in PAGINATING mode (always 1)
DEFAULT_PAGINATING_PAGE_SIZE = 50 // Jobs per page in PAGINATING mode (with pagination)
)
// JobCollectionConfig holds the configuration for job collection
type JobCollectionConfig struct {
Mode string
PageSize int
InputStep int
BatchingInputStep int
BatchingPageSize int
PaginatingPageSize int
}
// getJobCollectionConfig reads configuration from environment variables with fallback to defaults
func getJobCollectionConfig(taskCtx plugin.SubTaskContext) *JobCollectionConfig {
cfg := taskCtx.TaskContext().GetConfigReader()
config := &JobCollectionConfig{
Mode: DEFAULT_JOB_COLLECTION_MODE,
BatchingInputStep: DEFAULT_BATCHING_INPUT_STEP,
BatchingPageSize: DEFAULT_BATCHING_PAGE_SIZE,
PaginatingPageSize: DEFAULT_PAGINATING_PAGE_SIZE,
}
// Read collection mode from environment
if mode := taskCtx.TaskContext().GetConfig("GITHUB_GRAPHQL_JOB_COLLECTION_MODE"); mode != "" {
if mode == JOB_COLLECTION_MODE_BATCHING || mode == JOB_COLLECTION_MODE_PAGINATING {
config.Mode = mode
}
}
// Read batching input step (must be > 1)
if cfg.IsSet("GITHUB_GRAPHQL_JOB_BATCHING_INPUT_STEP") {
if step := cfg.GetInt("GITHUB_GRAPHQL_JOB_BATCHING_INPUT_STEP"); step > 1 {
config.BatchingInputStep = step
}
}
// Read page sizes
if cfg.IsSet("GITHUB_GRAPHQL_JOB_BATCHING_PAGE_SIZE") {
if size := cfg.GetInt("GITHUB_GRAPHQL_JOB_BATCHING_PAGE_SIZE"); size > 0 {
config.BatchingPageSize = size
}
}
if cfg.IsSet("GITHUB_GRAPHQL_JOB_PAGINATING_PAGE_SIZE") {
if size := cfg.GetInt("GITHUB_GRAPHQL_JOB_PAGINATING_PAGE_SIZE"); size > 0 {
config.PaginatingPageSize = size
}
}
// Set derived values based on mode
if config.Mode == JOB_COLLECTION_MODE_PAGINATING {
config.PageSize = config.PaginatingPageSize
config.InputStep = PAGINATING_INPUT_STEP // Always 1 for paginating
} else {
config.PageSize = config.BatchingPageSize
config.InputStep = config.BatchingInputStep // User-configurable for batching
}
return config
}
// Batch mode: query multiple runs at once (array of nodes)
type GraphqlQueryCheckRunWrapperBatch struct {
RateLimit struct {
Cost int
}
Node []GraphqlQueryCheckSuite `graphql:"node(id: $id)" graphql-extend:"true"`
}
// Paginating mode: query single run (single node)
type GraphqlQueryCheckRunWrapperSingle struct {
RateLimit struct {
Cost int
}
Node GraphqlQueryCheckSuite `graphql:"node(id: $id)"`
}
type GraphqlQueryCheckSuite struct {
Id string
Typename string `graphql:"__typename"`
// equal to Run in rest
CheckSuite struct {
WorkflowRun struct {
DatabaseId int
}
// equal to Job in rest
CheckRuns struct {
TotalCount int
PageInfo struct {
EndCursor string `graphql:"endCursor"`
HasNextPage bool `graphql:"hasNextPage"`
}
Nodes []GraphqlQueryCheckRun
} `graphql:"checkRuns(first: $pageSize, after: $skipCursor)"`
} `graphql:"... on CheckSuite"`
}
type GraphqlQueryCheckRun struct {
Id string
Name string
DetailsUrl string
DatabaseId int
Status string
StartedAt *time.Time
Conclusion string
CompletedAt *time.Time
// ExternalId string
// Url string
// Title interface{}
// Text interface{}
// Summary interface{}
Steps struct {
TotalCount int
Nodes []struct {
CompletedAt *time.Time `json:"completed_at"`
Conclusion string `json:"conclusion"`
Name string `json:"name"`
Number int `json:"number"`
SecondsToCompletion int `json:"seconds_to_completion"`
StartedAt *time.Time `json:"started_at"`
Status string `json:"status"`
}
} `graphql:"steps(first: 50)"`
}
type SimpleWorkflowRun struct {
CheckSuiteNodeID string
}
// DbCheckRun is used to store additional fields (like RunId) required for database storage
// and application logic, while embedding the GraphqlQueryCheckRun struct for API data.
type DbCheckRun struct {
RunId int // WorkflowRunId, required for DORA calculation
*GraphqlQueryCheckRun
}
var CollectJobsMeta = plugin.SubTaskMeta{
Name: "Collect Job Runs",
EntryPoint: CollectJobs,
EnabledByDefault: true,
Description: "Collect Jobs(CheckRun) data from GithubGraphql api, supports both timeFilter and diffSync.",
DomainTypes: []string{plugin.DOMAIN_TYPE_CICD},
}
var _ plugin.SubTaskEntryPoint = CollectJobs
// createGetPageInfoFunc returns the appropriate page info function based on collection mode
func createGetPageInfoFunc(mode string) func(interface{}, *helper.GraphqlCollectorArgs) (*helper.GraphqlQueryPageInfo, error) {
if mode == JOB_COLLECTION_MODE_PAGINATING {
// PAGINATING mode: supports full pagination
return func(query interface{}, args *helper.GraphqlCollectorArgs) (*helper.GraphqlQueryPageInfo, error) {
queryWrapper := query.(*GraphqlQueryCheckRunWrapperSingle)
return &helper.GraphqlQueryPageInfo{
EndCursor: queryWrapper.Node.CheckSuite.CheckRuns.PageInfo.EndCursor,
HasNextPage: queryWrapper.Node.CheckSuite.CheckRuns.PageInfo.HasNextPage,
}, nil
}
}
// BATCHING mode: no pagination support
return func(query interface{}, args *helper.GraphqlCollectorArgs) (*helper.GraphqlQueryPageInfo, error) {
return &helper.GraphqlQueryPageInfo{
EndCursor: "",
HasNextPage: false,
}, nil
}
}
// createBuildQueryFunc returns the appropriate build query function based on collection mode
func createBuildQueryFunc(mode string) func(*helper.GraphqlRequestData) (interface{}, map[string]interface{}, error) {
if mode == JOB_COLLECTION_MODE_PAGINATING {
// PAGINATING mode: single run per request
return func(reqData *helper.GraphqlRequestData) (interface{}, map[string]interface{}, error) {
if reqData == nil {
return &GraphqlQueryCheckRunWrapperSingle{}, map[string]interface{}{}, nil
}
workflowRun := reqData.Input.(*SimpleWorkflowRun)
query := &GraphqlQueryCheckRunWrapperSingle{}
variables := map[string]interface{}{
"id": graphql.ID(workflowRun.CheckSuiteNodeID),
"pageSize": graphql.Int(reqData.Pager.Size),
"skipCursor": (*graphql.String)(reqData.Pager.SkipCursor),
}
return query, variables, nil
}
}
// BATCHING mode: multiple runs per request
return func(reqData *helper.GraphqlRequestData) (interface{}, map[string]interface{}, error) {
if reqData == nil {
return &GraphqlQueryCheckRunWrapperBatch{}, map[string]interface{}{}, nil
}
workflowRuns := reqData.Input.([]interface{})
query := &GraphqlQueryCheckRunWrapperBatch{}
checkSuiteIds := []map[string]interface{}{}
for _, iWorkflowRuns := range workflowRuns {
workflowRun := iWorkflowRuns.(*SimpleWorkflowRun)
checkSuiteIds = append(checkSuiteIds, map[string]interface{}{
`id`: graphql.ID(workflowRun.CheckSuiteNodeID),
})
}
variables := map[string]interface{}{
"node": checkSuiteIds,
"pageSize": graphql.Int(reqData.Pager.Size),
"skipCursor": (*graphql.String)(reqData.Pager.SkipCursor),
}
return query, variables, nil
}
}
func CollectJobs(taskCtx plugin.SubTaskContext) errors.Error {
db := taskCtx.GetDal()
data := taskCtx.GetData().(*githubTasks.GithubTaskData)
logger := taskCtx.GetLogger()
// Get configuration from environment variables or defaults
config := getJobCollectionConfig(taskCtx)
logger.Info("GitHub Job Collector - Mode: %s, InputStep: %d, PageSize: %d",
config.Mode, config.InputStep, config.PageSize)
apiCollector, err := helper.NewStatefulApiCollector(helper.RawDataSubTaskArgs{
Ctx: taskCtx,
Params: githubTasks.GithubApiParams{
ConnectionId: data.Options.ConnectionId,
Name: data.Options.Name,
},
Table: RAW_GRAPHQL_JOBS_TABLE,
})
if err != nil {
return err
}
clauses := []dal.Clause{
dal.Select("check_suite_node_id"),
dal.From(models.GithubRun{}.TableName()),
dal.Where("repo_id = ? and connection_id=?", data.Options.GithubId, data.Options.ConnectionId),
dal.Orderby("github_updated_at DESC"),
}
if apiCollector.IsIncremental() && apiCollector.GetSince() != nil {
clauses = append(clauses, dal.Where("github_updated_at > ?", *apiCollector.GetSince()))
}
cursor, err := db.Cursor(clauses...)
if err != nil {
return err
}
defer cursor.Close()
iterator, err := helper.NewDalCursorIterator(db, cursor, reflect.TypeOf(SimpleWorkflowRun{}))
if err != nil {
return err
}
// Create closures that capture the runtime mode configuration
buildQueryFunc := createBuildQueryFunc(config.Mode)
var getPageInfoFunc func(interface{}, *helper.GraphqlCollectorArgs) (*helper.GraphqlQueryPageInfo, error)
if config.Mode == JOB_COLLECTION_MODE_PAGINATING {
getPageInfoFunc = createGetPageInfoFunc(config.Mode) // Enable pagination
} else {
getPageInfoFunc = nil // Disable pagination for BATCHING mode
}
err = apiCollector.InitGraphQLCollector(helper.GraphqlCollectorArgs{
Input: iterator,
InputStep: config.InputStep,
GraphqlClient: data.GraphqlClient,
BuildQuery: buildQueryFunc,
GetPageInfo: getPageInfoFunc, // nil for BATCHING, function for PAGINATING
ResponseParser: func(queryWrapper any) (messages []json.RawMessage, err errors.Error) {
if config.Mode == JOB_COLLECTION_MODE_PAGINATING {
// Single node processing
query := queryWrapper.(*GraphqlQueryCheckRunWrapperSingle)
node := query.Node
runId := node.CheckSuite.WorkflowRun.DatabaseId
for _, checkRun := range node.CheckSuite.CheckRuns.Nodes {
dbCheckRun := &DbCheckRun{
RunId: runId,
GraphqlQueryCheckRun: &checkRun,
}
// A checkRun without a startedAt time is a run that was never started (skipped), GitHub returns
// a ZeroTime (Due to the GO implementation) for startedAt, so we need to check for that here.
dbCheckRun.StartedAt = utils.NilIfZeroTime(dbCheckRun.StartedAt)
dbCheckRun.CompletedAt = utils.NilIfZeroTime(dbCheckRun.CompletedAt)
updatedAt := dbCheckRun.StartedAt
if dbCheckRun.CompletedAt != nil {
updatedAt = dbCheckRun.CompletedAt
}
if apiCollector.GetSince() != nil && !apiCollector.GetSince().Before(*updatedAt) {
return messages, helper.ErrFinishCollect
}
messages = append(messages, errors.Must1(json.Marshal(dbCheckRun)))
}
} else {
// Batch processing (multiple nodes)
query := queryWrapper.(*GraphqlQueryCheckRunWrapperBatch)
for _, node := range query.Node {
runId := node.CheckSuite.WorkflowRun.DatabaseId
for _, checkRun := range node.CheckSuite.CheckRuns.Nodes {
dbCheckRun := &DbCheckRun{
RunId: runId,
GraphqlQueryCheckRun: &checkRun,
}
// A checkRun without a startedAt time is a run that was never started (skipped), GitHub returns
// a ZeroTime (Due to the GO implementation) for startedAt, so we need to check for that here.
dbCheckRun.StartedAt = utils.NilIfZeroTime(dbCheckRun.StartedAt)
dbCheckRun.CompletedAt = utils.NilIfZeroTime(dbCheckRun.CompletedAt)
updatedAt := dbCheckRun.StartedAt
if dbCheckRun.CompletedAt != nil {
updatedAt = dbCheckRun.CompletedAt
}
if apiCollector.GetSince() != nil && !apiCollector.GetSince().Before(*updatedAt) {
return messages, helper.ErrFinishCollect
}
messages = append(messages, errors.Must1(json.Marshal(dbCheckRun)))
}
}
}
return
},
IgnoreQueryErrors: true,
PageSize: config.PageSize,
})
if err != nil {
return err
}
return apiCollector.Execute()
}