Skip to content

Commit d6e71a9

Browse files
2 parents 6844eb7 + ceb7e6b commit d6e71a9

141 files changed

Lines changed: 8053 additions & 170 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/helpers/pluginhelper/api/graphql_async_client.go

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@ package api
2020
import (
2121
"context"
2222
"fmt"
23+
"strconv"
24+
"sync"
25+
"time"
26+
2327
"github.com/apache/incubator-devlake/core/errors"
2428
"github.com/apache/incubator-devlake/core/log"
2529
"github.com/apache/incubator-devlake/core/plugin"
2630
"github.com/apache/incubator-devlake/core/utils"
27-
"sync"
28-
"time"
2931

3032
"github.com/merico-ai/graphql"
3133
)
@@ -47,30 +49,52 @@ type GraphqlAsyncClient struct {
4749
getRateCost func(q interface{}) int
4850
}
4951

52+
// defaultRateLimitConst is the generic fallback rate limit for GraphQL requests.
53+
// It is used as the initial remaining quota when dynamic rate limit
54+
// information is unavailable from the provider.
55+
const defaultRateLimitConst = 1000
56+
5057
// CreateAsyncGraphqlClient creates a new GraphqlAsyncClient
5158
func CreateAsyncGraphqlClient(
5259
taskCtx plugin.TaskContext,
5360
graphqlClient *graphql.Client,
5461
logger log.Logger,
5562
getRateRemaining func(context.Context, *graphql.Client, log.Logger) (rateRemaining int, resetAt *time.Time, err errors.Error),
63+
opts ...func(*GraphqlAsyncClient),
5664
) (*GraphqlAsyncClient, errors.Error) {
5765
ctxWithCancel, cancel := context.WithCancel(taskCtx.GetContext())
66+
5867
graphqlAsyncClient := &GraphqlAsyncClient{
5968
ctx: ctxWithCancel,
6069
cancel: cancel,
6170
client: graphqlClient,
6271
logger: logger,
6372
rateExhaustCond: sync.NewCond(&sync.Mutex{}),
64-
rateRemaining: 0,
73+
rateRemaining: defaultRateLimitConst,
6574
getRateRemaining: getRateRemaining,
6675
}
6776

77+
// apply options
78+
for _, opt := range opts {
79+
opt(graphqlAsyncClient)
80+
}
81+
82+
// Env config wins over everything, only if explicitly set
83+
if rateLimit := resolveRateLimit(taskCtx, logger); rateLimit != -1 {
84+
logger.Info("GRAPHQL_RATE_LIMIT env override applied: %d (was %d)", rateLimit, graphqlAsyncClient.rateRemaining)
85+
graphqlAsyncClient.rateRemaining = rateLimit
86+
}
87+
6888
if getRateRemaining != nil {
6989
rateRemaining, resetAt, err := getRateRemaining(taskCtx.GetContext(), graphqlClient, logger)
7090
if err != nil {
71-
panic(err)
91+
graphqlAsyncClient.logger.Info("failed to fetch initial graphql rate limit, fallback to default: %v", err)
92+
graphqlAsyncClient.updateRateRemaining(graphqlAsyncClient.rateRemaining, nil)
93+
} else {
94+
graphqlAsyncClient.updateRateRemaining(rateRemaining, resetAt)
7295
}
73-
graphqlAsyncClient.updateRateRemaining(rateRemaining, resetAt)
96+
} else {
97+
graphqlAsyncClient.updateRateRemaining(graphqlAsyncClient.rateRemaining, nil)
7498
}
7599

76100
// load retry/timeout from configuration
@@ -115,6 +139,10 @@ func (apiClient *GraphqlAsyncClient) updateRateRemaining(rateRemaining int, rese
115139
apiClient.rateExhaustCond.Signal()
116140
}
117141
go func() {
142+
if apiClient.getRateRemaining == nil {
143+
return
144+
}
145+
118146
nextDuring := 3 * time.Minute
119147
if resetAt != nil && resetAt.After(time.Now()) {
120148
nextDuring = time.Until(*resetAt)
@@ -126,7 +154,15 @@ func (apiClient *GraphqlAsyncClient) updateRateRemaining(rateRemaining int, rese
126154
case <-time.After(nextDuring):
127155
newRateRemaining, newResetAt, err := apiClient.getRateRemaining(apiClient.ctx, apiClient.client, apiClient.logger)
128156
if err != nil {
129-
panic(err)
157+
apiClient.logger.Info("failed to update graphql rate limit, will retry next cycle: %v", err)
158+
// Floor the reused value so Signal() always fires; prevents deadlock when
159+
// rateRemaining is 0 and the rate-limit endpoint keeps erroring (e.g. GHE).
160+
fallback := apiClient.rateRemaining
161+
if fallback < defaultRateLimitConst {
162+
fallback = defaultRateLimitConst
163+
}
164+
apiClient.updateRateRemaining(fallback, nil)
165+
return
130166
}
131167
apiClient.updateRateRemaining(newRateRemaining, newResetAt)
132168
}
@@ -218,3 +254,25 @@ func (apiClient *GraphqlAsyncClient) Wait() {
218254
func (apiClient *GraphqlAsyncClient) Release() {
219255
apiClient.cancel()
220256
}
257+
258+
// WithFallbackRateLimit sets the initial/fallback rate limit used when
259+
// rate limit information cannot be fetched dynamically.
260+
// This value may be overridden later by getRateRemaining.
261+
func WithFallbackRateLimit(limit int) func(*GraphqlAsyncClient) {
262+
return func(c *GraphqlAsyncClient) {
263+
if limit > 0 {
264+
c.rateRemaining = limit
265+
}
266+
}
267+
}
268+
269+
// resolveRateLimit returns -1 if GRAPHQL_RATE_LIMIT is not set or invalid
270+
func resolveRateLimit(taskCtx plugin.TaskContext, logger log.Logger) int {
271+
if v := taskCtx.GetConfig("GRAPHQL_RATE_LIMIT"); v != "" {
272+
if parsed, err := strconv.Atoi(v); err == nil {
273+
return parsed
274+
}
275+
logger.Warn(nil, "invalid GRAPHQL_RATE_LIMIT, using default")
276+
}
277+
return -1
278+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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 e2e
19+
20+
import (
21+
"reflect"
22+
"sort"
23+
"testing"
24+
25+
"github.com/apache/incubator-devlake/helpers/e2ehelper"
26+
"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
27+
"github.com/apache/incubator-devlake/plugins/circleci/impl"
28+
"github.com/apache/incubator-devlake/plugins/circleci/models"
29+
"github.com/apache/incubator-devlake/plugins/circleci/tasks"
30+
"github.com/stretchr/testify/assert"
31+
)
32+
33+
// TestCircleciUnfinishedJobsInputIterator is a regression test for
34+
// https://github.com/apache/devlake/issues/8907. The "collect unfinished job
35+
// details" collector builds its URL from "/v2/workflow/{{ .Input.Id }}/job" while
36+
// scanning rows into a models.CircleciJob. Its input query must therefore expose the
37+
// workflow id in the row's Id field; a bare "DISTINCT workflow_id" left Id empty and
38+
// produced "/v2/workflow//job" (HTTP 500). This test runs the production query
39+
// (tasks.UnfinishedJobsInputClauses) through the real iterator and asserts each
40+
// yielded row's Id is the workflow id, that results are DISTINCT, and that the
41+
// status/connection filters hold.
42+
func TestCircleciUnfinishedJobsInputIterator(t *testing.T) {
43+
var circleci impl.Circleci
44+
dataflowTester := e2ehelper.NewDataFlowTester(t, "circleci", circleci)
45+
46+
const projectSlug = "github/test/repo"
47+
dataflowTester.FlushTabler(&models.CircleciJob{})
48+
49+
seed := []models.CircleciJob{
50+
{ConnectionId: 1, WorkflowId: "wf-onhold", Id: "job-1", ProjectSlug: projectSlug, Status: "on_hold"},
51+
{ConnectionId: 1, WorkflowId: "wf-onhold", Id: "job-2", ProjectSlug: projectSlug, Status: "running"}, // same workflow -> DISTINCT
52+
{ConnectionId: 1, WorkflowId: "wf-queued", Id: "job-3", ProjectSlug: projectSlug, Status: "queued"},
53+
{ConnectionId: 1, WorkflowId: "wf-success", Id: "job-4", ProjectSlug: projectSlug, Status: "success"}, // terminal -> excluded
54+
{ConnectionId: 2, WorkflowId: "wf-otherconn", Id: "job-5", ProjectSlug: projectSlug, Status: "on_hold"}, // other connection -> excluded
55+
}
56+
for i := range seed {
57+
assert.Nil(t, dataflowTester.Dal.Create(&seed[i]))
58+
}
59+
60+
cursor, err := dataflowTester.Dal.Cursor(tasks.UnfinishedJobsInputClauses(1, projectSlug)...)
61+
assert.Nil(t, err)
62+
iter, err := api.NewDalCursorIterator(dataflowTester.Dal, cursor, reflect.TypeOf(models.CircleciJob{}))
63+
assert.Nil(t, err)
64+
defer iter.Close()
65+
66+
var ids []string
67+
for iter.HasNext() {
68+
item, err := iter.Fetch()
69+
assert.Nil(t, err)
70+
job := item.(*models.CircleciJob)
71+
ids = append(ids, job.Id)
72+
}
73+
sort.Strings(ids)
74+
75+
// Distinct workflow ids for connection 1's non-terminal jobs, with Id populated
76+
// (the URL template reads .Input.Id). wf-success (terminal) and wf-otherconn
77+
// (connection 2) are excluded.
78+
assert.Equal(t, []string{"wf-onhold", "wf-queued"}, ids)
79+
for _, id := range ids {
80+
assert.NotEmpty(t, id, "Input.Id must be the workflow id, not empty (#8907)")
81+
}
82+
}

backend/plugins/circleci/tasks/job_collector.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,20 @@ var CollectJobsMeta = plugin.SubTaskMeta{
4141
DomainTypes: []string{plugin.DOMAIN_TYPE_CICD},
4242
}
4343

44+
// UnfinishedJobsInputClauses returns the DAL clauses that select the workflows whose
45+
// jobs are still in a non-terminal status and therefore need their job details
46+
// recollected by the CollectJobs "unfinished details" collector.
47+
func UnfinishedJobsInputClauses(connectionId uint64, projectSlug string) []dal.Clause {
48+
return []dal.Clause{
49+
dal.Select("DISTINCT workflow_id AS id"), // #8907: alias to id so {{ .Input.Id }} resolves when scanned into CircleciJob
50+
dal.From(&models.CircleciJob{}),
51+
dal.Where(
52+
"connection_id = ? AND project_slug = ? AND status IN ('running', 'not_running', 'queued', 'on_hold')",
53+
connectionId, projectSlug,
54+
),
55+
}
56+
}
57+
4458
func CollectJobs(taskCtx plugin.SubTaskContext) errors.Error {
4559
rawDataSubTaskArgs, data := CreateRawDataSubTaskArgs(taskCtx, RAW_JOB_TABLE)
4660
logger := taskCtx.GetLogger()
@@ -94,14 +108,8 @@ func CollectJobs(taskCtx plugin.SubTaskContext) errors.Error {
94108
AfterResponse: ignoreDeletedBuilds,
95109
},
96110
BuildInputIterator: func() (api.Iterator, errors.Error) {
97-
clauses := []dal.Clause{
98-
dal.Select("DISTINCT workflow_id"), // Only need to recollect jobs for a workflow once
99-
dal.From(&models.CircleciJob{}),
100-
dal.Where("connection_id = ? AND project_slug = ? AND status IN ('running', 'not_running', 'queued', 'on_hold')", data.Options.ConnectionId, data.Options.ProjectSlug),
101-
}
102-
103111
db := taskCtx.GetDal()
104-
cursor, err := db.Cursor(clauses...)
112+
cursor, err := db.Cursor(UnfinishedJobsInputClauses(data.Options.ConnectionId, data.Options.ProjectSlug)...)
105113
if err != nil {
106114
return nil, err
107115
}
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
connection_id,scope_id,day,enterprise_id,daily_active_users,weekly_active_users,monthly_active_users,monthly_active_chat_users,monthly_active_agent_users,pr_total_reviewed,pr_total_created,pr_total_created_by_copilot,pr_total_reviewed_by_copilot,user_initiated_interaction_count,code_generation_activity_count,code_acceptance_activity_count,loc_suggested_to_add_sum,loc_suggested_to_delete_sum,loc_added_sum,loc_deleted_sum
2-
1,octodemo,2025-09-01T00:00:00.000+00:00,,10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
3-
1,octodemo,2025-09-02T00:00:00.000+00:00,,12,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
1+
connection_id,scope_id,day,enterprise_id,daily_active_users,weekly_active_users,monthly_active_users,monthly_active_chat_users,monthly_active_agent_users,daily_active_cli_users,daily_active_copilot_code_review_users,daily_passive_copilot_code_review_users,weekly_active_copilot_code_review_users,weekly_passive_copilot_code_review_users,monthly_active_copilot_code_review_users,monthly_passive_copilot_code_review_users,chat_panel_agent_mode,chat_panel_ask_mode,chat_panel_custom_mode,chat_panel_edit_mode,chat_panel_plan_mode,chat_panel_unknown_mode,pr_total_reviewed,pr_total_created,pr_total_merged,pr_median_minutes_to_merge,pr_total_suggestions,pr_total_applied_suggestions,pr_total_created_by_copilot,pr_total_reviewed_by_copilot,pr_total_merged_created_by_copilot,pr_total_merged_reviewed_by_copilot,pr_median_min_to_merge_copilot_authored,pr_median_min_to_merge_copilot_reviewed,pr_total_copilot_suggestions,pr_total_copilot_applied_suggestions,user_initiated_interaction_count,code_generation_activity_count,code_acceptance_activity_count,loc_suggested_to_add_sum,loc_suggested_to_delete_sum,loc_added_sum,loc_deleted_sum,cli_session_count,cli_request_count,cli_prompt_count,cli_output_token_sum,cli_prompt_token_sum
2+
1,octodemo,2025-09-01T00:00:00.000+00:00,,10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
3+
1,octodemo,2025-09-02T00:00:00.000+00:00,,12,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
connection_id,organization,user_login,user_id,plan_type,created_at,last_activity_at,last_activity_editor,last_authenticated_at,pending_cancellation_date,updated_at
2-
1,octodemo,nathos,4215,enterprise,2023-08-28T23:50:42.000+00:00,2025-11-06T16:12:15.000+00:00,copilot_pr_review,2025-12-04T15:53:22.000+00:00,,2024-02-01T00:00:00.000+00:00
3-
1,octodemo,octocat,1,enterprise,2024-01-10T10:11:12.000+00:00,,vscode/1.0.0/copilot-chat/0.1.0,,,2024-02-02T00:00:00.000+00:00
1+
connection_id,organization,user_login,user_id,user_name,user_email,plan_type,assigning_team_id,assigning_team_name,assigning_team_slug,created_at,last_activity_at,last_activity_editor,last_authenticated_at,pending_cancellation_date,updated_at
2+
1,octodemo,nathos,4215,,,enterprise,0,,,2023-08-28T23:50:42.000+00:00,2025-11-06T16:12:15.000+00:00,copilot_pr_review,2025-12-04T15:53:22.000+00:00,,2024-02-01T00:00:00.000+00:00
3+
1,octodemo,octocat,1,,,enterprise,0,,,2024-01-10T10:11:12.000+00:00,,vscode/1.0.0/copilot-chat/0.1.0,,,2024-02-02T00:00:00.000+00:00

backend/plugins/gh-copilot/models/enterprise_metrics.go

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ type CopilotCodeMetrics struct {
4444
LocDeletedSum int `json:"locDeletedSum"`
4545
}
4646

47+
// CopilotCliMetrics contains CLI usage breakdown metrics.
48+
type CopilotCliMetrics struct {
49+
CliSessionCount int `json:"cliSessionCount" gorm:"comment:Number of CLI sessions"`
50+
CliRequestCount int `json:"cliRequestCount" gorm:"comment:Number of CLI requests"`
51+
CliPromptCount int `json:"cliPromptCount" gorm:"comment:Number of CLI prompts"`
52+
CliOutputTokenSum int `json:"cliOutputTokenSum" gorm:"comment:Total output tokens from CLI"`
53+
CliPromptTokenSum int `json:"cliPromptTokenSum" gorm:"comment:Total prompt tokens from CLI"`
54+
}
55+
4756
// GhCopilotEnterpriseDailyMetrics captures daily enterprise-level aggregate Copilot metrics.
4857
type GhCopilotEnterpriseDailyMetrics struct {
4958
ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"`
@@ -57,12 +66,43 @@ type GhCopilotEnterpriseDailyMetrics struct {
5766
MonthlyActiveChatUsers int `json:"monthlyActiveChatUsers"`
5867
MonthlyActiveAgentUsers int `json:"monthlyActiveAgentUsers"`
5968

60-
PRTotalReviewed int `json:"prTotalReviewed" gorm:"comment:Total PRs reviewed"`
61-
PRTotalCreated int `json:"prTotalCreated" gorm:"comment:Total PRs created"`
62-
PRTotalCreatedByCopilot int `json:"prTotalCreatedByCopilot" gorm:"comment:PRs created by Copilot"`
63-
PRTotalReviewedByCopilot int `json:"prTotalReviewedByCopilot" gorm:"comment:PRs reviewed by Copilot"`
69+
// CLI active users
70+
DailyActiveCliUsers int `json:"dailyActiveCliUsers" gorm:"comment:Daily active CLI users"`
71+
72+
// Code review user counts
73+
DailyActiveCopilotCodeReviewUsers int `json:"dailyActiveCopilotCodeReviewUsers"`
74+
DailyPassiveCopilotCodeReviewUsers int `json:"dailyPassiveCopilotCodeReviewUsers"`
75+
WeeklyActiveCopilotCodeReviewUsers int `json:"weeklyActiveCopilotCodeReviewUsers"`
76+
WeeklyPassiveCopilotCodeReviewUsers int `json:"weeklyPassiveCopilotCodeReviewUsers"`
77+
MonthlyActiveCopilotCodeReviewUsers int `json:"monthlyActiveCopilotCodeReviewUsers"`
78+
MonthlyPassiveCopilotCodeReviewUsers int `json:"monthlyPassiveCopilotCodeReviewUsers"`
79+
80+
// Chat panel mode breakdown
81+
ChatPanelAgentMode int `json:"chatPanelAgentMode" gorm:"comment:Chat panel agent mode interactions"`
82+
ChatPanelAskMode int `json:"chatPanelAskMode" gorm:"comment:Chat panel ask mode interactions"`
83+
ChatPanelCustomMode int `json:"chatPanelCustomMode" gorm:"comment:Chat panel custom mode interactions"`
84+
ChatPanelEditMode int `json:"chatPanelEditMode" gorm:"comment:Chat panel edit mode interactions"`
85+
ChatPanelPlanMode int `json:"chatPanelPlanMode" gorm:"comment:Chat panel plan mode interactions"`
86+
ChatPanelUnknownMode int `json:"chatPanelUnknownMode" gorm:"comment:Chat panel unknown mode interactions"`
87+
88+
// Pull request metrics (expanded)
89+
PRTotalReviewed int `json:"prTotalReviewed" gorm:"comment:Total PRs reviewed"`
90+
PRTotalCreated int `json:"prTotalCreated" gorm:"comment:Total PRs created"`
91+
PRTotalMerged int `json:"prTotalMerged" gorm:"comment:Total PRs merged"`
92+
PRMedianMinutesToMerge float64 `json:"prMedianMinutesToMerge" gorm:"comment:Median minutes to merge PRs"`
93+
PRTotalSuggestions int `json:"prTotalSuggestions" gorm:"comment:Total PR review suggestions"`
94+
PRTotalAppliedSuggestions int `json:"prTotalAppliedSuggestions" gorm:"comment:Total applied PR suggestions"`
95+
PRTotalCreatedByCopilot int `json:"prTotalCreatedByCopilot" gorm:"comment:PRs created by Copilot"`
96+
PRTotalReviewedByCopilot int `json:"prTotalReviewedByCopilot" gorm:"comment:PRs reviewed by Copilot"`
97+
PRTotalMergedCreatedByCopilot int `json:"prTotalMergedCreatedByCopilot" gorm:"comment:Merged PRs created by Copilot"`
98+
PRTotalMergedReviewedByCopilot int `json:"prTotalMergedReviewedByCopilot" gorm:"comment:Merged PRs reviewed by Copilot"`
99+
PRMedianMinToMergeCopilotAuthored float64 `json:"prMedianMinToMergeCopilotAuthored" gorm:"comment:Median min to merge Copilot-authored PRs"`
100+
PRMedianMinToMergeCopilotReviewed float64 `json:"prMedianMinToMergeCopilotReviewed" gorm:"comment:Median min to merge Copilot-reviewed PRs"`
101+
PRTotalCopilotSuggestions int `json:"prTotalCopilotSuggestions" gorm:"comment:Total Copilot review suggestions"`
102+
PRTotalCopilotAppliedSuggestions int `json:"prTotalCopilotAppliedSuggestions" gorm:"comment:Total Copilot applied suggestions"`
64103

65104
CopilotActivityMetrics `mapstructure:",squash"`
105+
CopilotCliMetrics `mapstructure:",squash"`
66106
common.NoPKModel
67107
}
68108

0 commit comments

Comments
 (0)