diff --git a/backend/plugins/cursor/README.md b/backend/plugins/cursor/README.md index 30e43b8468f..07ef26d81c7 100644 --- a/backend/plugins/cursor/README.md +++ b/backend/plugins/cursor/README.md @@ -31,6 +31,15 @@ It follows the same structure and patterns as other DevLake AI usage plugins (no | `/teams/filtered-usage-events` | POST | Event-level usage and charges | | `/teams/daily-usage-data` | POST | Per-user per-day adoption metrics | +**Cursor Enterprise API endpoints (Enterprise keys only -- skipped for Team keys):** + +| Endpoint | Method | Data | +|----------|--------|------| +| `/analytics/ai-code/commits` | GET | Per-commit AI line attribution (TAB vs Composer vs non-AI) | +| `/analytics/ai-code/commits/:commitHash` | GET | Commit-detail file blame and conversation metadata (limited alpha) | +| `/analytics/ai-code/changes` | GET | Granular accepted AI changes with per-file metadata | +| `/analytics/team/conversation-insights` | GET | Aggregate conversation work classifications (intents, categories, complexity) | + **Stored data (tool layer):** | Table | Description | @@ -39,6 +48,11 @@ It follows the same structure and patterns as other DevLake AI usage plugins (no | `_tool_cursor_usage_events` | Billable usage events with model, tokens, and charged amounts | | `_tool_cursor_user_spend` | Per-user spend for the current billing cycle (on-demand and included) | | `_tool_cursor_daily_usage` | Daily adoption metrics: completions, requests by feature, tab acceptance, line edits | +| `_tool_cursor_ai_code_commits` | Per-commit AI line attribution: TAB, Composer, and non-AI lines (Enterprise only) | +| `_tool_cursor_ai_code_range_annotations` | File-level blame rows from commit-detail API with conversation and model (Enterprise alpha) | +| `_tool_cursor_ai_code_conversations` | Conversation metadata referenced by commit-detail range annotations (Enterprise alpha) | +| `_tool_cursor_ai_code_changes` | Accepted AI changes with source, model, and per-file metadata (Enterprise only) | +| `_tool_cursor_conversation_insights` | Flattened Conversation Insights metrics: intents, categories, complexity, guidance, work types (Enterprise only) | Data is collected in the **Raw → Tool** layers only. There is no domain-layer converter in this plugin; Grafana dashboards query `_tool_cursor_*` tables directly. @@ -60,6 +74,10 @@ flowchart LR 2. `collectUsageEvents` → `extractUsageEvents` 3. `collectUserSpend` → `extractUserSpend` 4. `collectDailyUsage` → `extractDailyUsage` +5. `collectAiCodeCommits` → `extractAiCodeCommits` *(Enterprise only -- skipped for Team keys)* +6. `collectAiCodeCommitDetails` → `extractAiCodeCommitDetails` *(Enterprise alpha -- skipped when commit-details inaccessible)* +7. `collectAiCodeChanges` → `extractAiCodeChanges` *(Enterprise only -- skipped for Team keys)* +8. `collectConversationInsights` → `extractConversationInsights` *(Enterprise only -- skipped when insights disabled or inaccessible)* ## Repository layout @@ -124,17 +142,19 @@ Run the blueprint on a daily schedule to keep usage and cost data current. - **Date range chunking**: both `/teams/daily-usage-data` and `/teams/filtered-usage-events` requests are split into **30-day** chunks (API limit for daily usage; applied to usage events for resilience). - **Extract**: `extractUsageEvents` and `extractDailyUsage` use a cursor-local stateful extractor (incremental by default). Incremental runs also promote any raw rows with `id > MAX(_raw_data_id)` already in the tool table, so collected-but-unpromoted raw data is healed without a full refresh. A config version bump (`extractorVersion`) triggers a one-time full re-extract after upgrade. - **Dashboards**: query `_tool_cursor_*` tables only. Legacy domain tables (`cursor_usage`, `cursor_team_events`) are unrelated and must not be used. -- **Rate limiting**: collectors honor `Retry-After` response headers and respect the configured `rateLimitPerHour`. +- **Rate limiting**: collectors honor `Retry-After` response headers and respect the configured `rateLimitPerHour`. The Admin API documents **20 requests/minute**; the connection default of **1,200/hour** (~20/min) matches that limit. Enterprise Analytics endpoints use separate quotas — **AI Code Tracking** (`/analytics/ai-code/*`) is **20 requests/minute**; team-level analytics (`/analytics/team/*`) is **100 requests/minute**. If enterprise collection triggers throttling, lower `rateLimitPerHour` on the connection. ## Dashboards -Grafana dashboard JSON lives under `grafana/dashboards/mysql/`: +Grafana dashboard JSON lives under both `grafana/dashboards/mysql/` and `grafana/dashboards/postgresql/`. PostgreSQL UIDs add a `-pg` suffix. -| Dashboard | File | UID | -|-----------|------|-----| -| Cursor Usage & Cost | `cursor-usage.json` | `cursor_usage` | -| AI Cost Efficiency (Cursor panels) | `ai-cost-efficiency.json` | — | -| Multi-AI Comparison (Cursor panels) | `multi-ai-comparison.json` | — | +| Dashboard | File | MySQL UID | PostgreSQL UID | +|-----------|------|-----------|----------------| +| Cursor Usage & Cost | `cursor-usage.json` | `cursor_usage` | `cursor_usage-pg` | +| Cursor Enterprise AI Code & Insights | `cursor-enterprise.json` | `cursor_enterprise` | `cursor_enterprise-pg` | +| Cursor BugBot Review Analytics | `cursor-bugbot.json` | `cursor_bugbot` | `cursor_bugbot-pg` | +| AI Cost Efficiency (Cursor panels) | `ai-cost-efficiency.json` | — | — | +| Multi-AI Comparison (Cursor panels) | `multi-ai-comparison.json` | — | — | ## Error handling @@ -149,30 +169,52 @@ Grafana dashboard JSON lives under `grafana/dashboards/mysql/`: Tokens are sanitized before persisting. Connection test results include a `permissions` object showing which Admin API endpoints succeeded. -## Not collected (Enterprise AI Code Tracking) +## Enterprise AI Code Tracking -The following endpoints from the [AI Code Tracking API](https://cursor.com/docs/account/teams/ai-code-tracking-api) are **Enterprise plan only** and are **not implemented** in this plugin (no collector, extractor, or `_tool_cursor_*` table): +The following endpoints from the [AI Code Tracking API](https://cursor.com/docs/account/teams/ai-code-tracking-api) are **Enterprise plan only** and are collected when the connection uses an Enterprise Admin API key: -| Endpoint | Purpose | -|----------|---------| -| `GET /analytics/ai-code/commits` | Per-commit AI line attribution (TAB vs Composer vs non-AI) | -| `GET /analytics/ai-code/changes` | Granular accepted AI changes | -| `GET /analytics/ai-code/commits.csv` / `changes.csv` | Bulk CSV exports of the above | +| Endpoint | Purpose | Tool table | +|----------|---------|------------| +| `GET /analytics/ai-code/commits` | Per-commit AI line attribution (TAB vs Composer vs non-AI) | `_tool_cursor_ai_code_commits` | +| `GET /analytics/ai-code/commits/:commitHash` | File-level blame and conversation metadata (limited alpha) | `_tool_cursor_ai_code_range_annotations`, `_tool_cursor_ai_code_conversations` | +| `GET /analytics/ai-code/changes` | Granular accepted AI changes | `_tool_cursor_ai_code_changes` | + +Team/Business Admin API keys receive **401** on these routes. `TestConnection` probes `/analytics/team/dau`, `/analytics/ai-code/commits`, and `/analytics/team/conversation-insights` to detect Enterprise access. The detected `KeyTier` (`personal`, `team`, or `enterprise`) is stored on the connection when you create, update the token, or test an existing connection. **Optional endpoints** (`hasBugbotReviews`, `hasConversationInsights`, `hasAiCodeCommitDetails`) are re-probed at the start of each pipeline run and persisted on the connection, so changes in the Cursor dashboard are picked up without a manual Test Connection. Enterprise collectors check `KeyTier` at runtime and skip silently for non-enterprise keys. Commit-detail collectors require `hasAiCodeCommitDetails` (limited alpha — probed via first commit hash from the list endpoint). Conversation Insights collectors also require `hasConversationInsights` on the connection (false when insights are disabled in Cursor team settings). + +## Conversation Insights -Team/Business Admin API keys typically receive **401** on these routes. Connection test optionally probes `GET /analytics/team/dau` (`probeEnterpriseAnalytics`) to detect Enterprise access; it does **not** ingest analytics data. +The following endpoint from the [Analytics API](https://cursor.com/docs/account/teams/analytics-api) is **Enterprise plan only** and is collected when the connection uses an Enterprise Admin API key with Conversation Insights enabled: -**Metrics this would unlock** (shown on the Cursor native dashboard but absent from DevLake today): +| Endpoint | Purpose | Tool table | +|----------|---------|------------| +| `GET /analytics/team/conversation-insights` | Aggregate work classifications (intents, complexity, categories, guidance levels, work types) | `_tool_cursor_conversation_insights` | -- AI share of **committed** code (not editor line acceptance) -- Per-commit `commitSource`: IDE, CLI, or cloud -- Repo name and primary-branch filters -- TAB vs Composer line attribution on commits +Returns **aggregate** insights only — no raw conversation content or conversation IDs. Collectors request all five `include` slices per date chunk. Optional access is re-probed at pipeline start; enable Conversation Insights in Cursor team settings and re-run the pipeline (Test Connection is optional but shows the current status in Config UI). -The **Cursor Usage** Grafana dashboard (`grafana/dashboards/mysql/cursor-usage.json`) uses Admin API proxies from `_tool_cursor_daily_usage` and `_tool_cursor_usage_events` instead; panel descriptions note where metrics are approximate. +## BugBot Review Analytics + +The following endpoint from the [BugBot Analytics API](https://cursor.com/docs/bugbot) is collected when `TestConnection` confirms access via `GET /analytics/team/bugbot-reviews`: + +| Endpoint | Purpose | Tool tables | +|----------|---------|-------------| +| `GET /analytics/team/bugbot-reviews` | Completed BugBot reviews with findings, cost, and resolution status | `_tool_cursor_bugbot_reviews`, `_tool_cursor_bugbot_findings` | + +Access is probed during connection test and persisted as `hasBugbotReviews` on the connection (may be available on Team or Enterprise keys). Collectors skip silently when the flag is false. + +**Not yet collected:** + +| Endpoint | Purpose | +|----------|---------| +| `GET /analytics/ai-code/commits.csv` / `changes.csv` | Bulk CSV exports (not needed; JSON pagination covers the same data) | +| `GET /analytics/team/agent-edits` | Agent edit metrics (suggested vs accepted diffs) | +| `GET /analytics/team/tabs` | Tab suggestion/accept/reject with line counts | +| `GET /analytics/team/dau` | CLI/Cloud/BugBot DAU breakdown | +| `GET /analytics/team/models` | Model usage by message count per day | +| `GET /analytics/by-user/*` | Per-user breakdowns of the above | ## Limitations -- **Team/Business Admin API only** — Enterprise-only Analytics API endpoints (`/analytics/*`) are not collected in this plugin (see [Not collected (Enterprise AI Code Tracking)](#not-collected-enterprise-ai-code-tracking) above). +- **Enterprise API endpoints are conditional** — AI Code Tracking (`/analytics/ai-code/*`) is collected only with Enterprise Admin keys. Conversation Insights runs when `hasConversationInsights` is true. BugBot review analytics (`/analytics/team/bugbot-reviews`) runs when `hasBugbotReviews` is true on the connection. Other Analytics endpoints (`/analytics/team/*`) are not yet collected (see sections above). - **Tool layer only** — no domain-layer tables; cross-plugin joins (Jira, GitHub PRs, etc.) are done in Grafana SQL or separate tooling. - **Team-level scope** — one scope per connection represents the whole team; per-team multi-tenant collection is not supported. - **Beta** — the plugin is marked beta in Config UI while the Admin API surface continues to evolve. diff --git a/backend/plugins/cursor/api/connection.go b/backend/plugins/cursor/api/connection.go index dcab18c0cc7..ed383061fc4 100644 --- a/backend/plugins/cursor/api/connection.go +++ b/backend/plugins/cursor/api/connection.go @@ -18,12 +18,14 @@ limitations under the License. package api import ( + gocontext "context" "strings" "github.com/apache/devlake/core/errors" "github.com/apache/devlake/core/plugin" helper "github.com/apache/devlake/helpers/pluginhelper/api" "github.com/apache/devlake/plugins/cursor/models" + "github.com/apache/devlake/plugins/cursor/service" ) // PostConnections creates a new Cursor connection. @@ -37,6 +39,9 @@ func PostConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, if err := validateConnection(connection); err != nil { return nil, err } + if err := service.PopulateKeyTier(gocontext.Background(), basicRes, connection); err != nil { + return nil, err + } if err := connectionHelper.Create(connection, input); err != nil { return nil, err @@ -49,6 +54,7 @@ func PatchConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, if err := connectionHelper.First(connection, input.Params); err != nil { return nil, err } + originalToken := connection.Token if err := (&models.CursorConnection{}).MergeFromRequest(connection, input.Body); err != nil { return nil, errors.Convert(err) } @@ -56,6 +62,11 @@ func PatchConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, if err := validateConnection(connection); err != nil { return nil, err } + if connection.Token != originalToken { + if err := service.PopulateKeyTier(gocontext.Background(), basicRes, connection); err != nil { + return nil, err + } + } if err := connectionHelper.SaveWithCreateOrUpdate(connection); err != nil { return nil, err } diff --git a/backend/plugins/cursor/api/test_connection.go b/backend/plugins/cursor/api/test_connection.go index 48fa21cc403..d48fdffd50a 100644 --- a/backend/plugins/cursor/api/test_connection.go +++ b/backend/plugins/cursor/api/test_connection.go @@ -49,22 +49,29 @@ func TestConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, // TestExistingConnection validates a stored Cursor connection with optional overrides. func TestExistingConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { - connection := &models.CursorConnection{} - if err := connectionHelper.First(connection, input.Params); err != nil { + stored := &models.CursorConnection{} + if err := connectionHelper.First(stored, input.Params); err != nil { return nil, plugin.WrapTestConnectionErrResp(basicRes, errors.BadInput.Wrap(err, "find connection from db")) } - if err := (&models.CursorConnection{}).MergeFromRequest(connection, input.Body); err != nil { + connection := *stored + if err := (&models.CursorConnection{}).MergeFromRequest(&connection, input.Body); err != nil { return nil, plugin.WrapTestConnectionErrResp(basicRes, errors.Convert(err)) } connection.Normalize() - if err := validateConnection(connection); err != nil { + if err := validateConnection(&connection); err != nil { return nil, plugin.WrapTestConnectionErrResp(basicRes, err) } - result, err := service.TestConnection(gocontext.Background(), basicRes, connection) + result, err := service.TestConnection(gocontext.Background(), basicRes, &connection) if err != nil { return nil, plugin.WrapTestConnectionErrResp(basicRes, err) } + if result != nil && result.KeyTier != "" { + service.ApplyTestResultToConnection(stored, result) + if err := connectionHelper.SaveWithCreateOrUpdate(stored); err != nil { + return nil, plugin.WrapTestConnectionErrResp(basicRes, err) + } + } return &plugin.ApiResourceOutput{Body: result, Status: http.StatusOK}, nil } diff --git a/backend/plugins/cursor/e2e/ai_code_commit_details_extractors_test.go b/backend/plugins/cursor/e2e/ai_code_commit_details_extractors_test.go new file mode 100644 index 00000000000..e7a8d15a461 --- /dev/null +++ b/backend/plugins/cursor/e2e/ai_code_commit_details_extractors_test.go @@ -0,0 +1,118 @@ +/* +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 e2e + +import ( + "testing" + "time" + + "github.com/apache/devlake/core/config" + "github.com/apache/devlake/core/models/common" + "github.com/apache/devlake/core/runner" + "github.com/apache/devlake/helpers/e2ehelper" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/cursor/impl" + "github.com/apache/devlake/plugins/cursor/models" + "github.com/apache/devlake/plugins/cursor/tasks" +) + +func TestCursorAiCodeCommitDetailsExtractorsDataFlow(t *testing.T) { + cfg := config.GetConfig() + dbUrl := cfg.GetString("E2E_DB_URL") + if dbUrl == "" { + t.Skip("skipping e2e test: E2E_DB_URL is not set") + } + if err := runner.CheckDbConnection(dbUrl, 10*time.Second); err != nil { + t.Skipf("skipping e2e test: cannot connect to E2E_DB_URL: %v", err) + } + + var cursorPlugin impl.Cursor + dataflowTester := e2ehelper.NewDataFlowTester(t, "cursor", cursorPlugin) + + taskData := &tasks.CursorTaskData{ + Options: &tasks.CursorOptions{ + ConnectionId: 1, + ScopeId: "team", + }, + Connection: &models.CursorConnection{ + CursorConn: models.CursorConn{ + RestConnection: helper.RestConnection{Endpoint: models.DefaultEndpoint}, + KeyTier: models.KeyTierEnterprise, + HasAiCodeCommitDetails: true, + }, + }, + } + + dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_cursor_ai_code_commit_details.csv", "_raw_cursor_ai_code_commit_details") + + dataflowTester.FlushTabler(&models.CursorAiCodeConversation{}) + dataflowTester.FlushTabler(&models.CursorAiCodeRangeAnnotation{}) + + dataflowTester.Subtask(tasks.ExtractAiCodeCommitDetailsMeta, taskData) + + dataflowTester.VerifyTableWithOptions(&models.CursorAiCodeConversation{}, e2ehelper.TableOptions{ + CSVRelPath: "./snapshot_tables/_tool_cursor_ai_code_conversations.csv", + IgnoreTypes: []interface{}{common.NoPKModel{}}, + }) + + dataflowTester.VerifyTableWithOptions(&models.CursorAiCodeRangeAnnotation{}, e2ehelper.TableOptions{ + CSVRelPath: "./snapshot_tables/_tool_cursor_ai_code_range_annotations.csv", + IgnoreTypes: []interface{}{common.NoPKModel{}}, + }) +} + +func TestCursorAiCodeCommitDetailsExtractorsSkipWhenInaccessible(t *testing.T) { + cfg := config.GetConfig() + dbUrl := cfg.GetString("E2E_DB_URL") + if dbUrl == "" { + t.Skip("skipping e2e test: E2E_DB_URL is not set") + } + if err := runner.CheckDbConnection(dbUrl, 10*time.Second); err != nil { + t.Skipf("skipping e2e test: cannot connect to E2E_DB_URL: %v", err) + } + + var cursorPlugin impl.Cursor + dataflowTester := e2ehelper.NewDataFlowTester(t, "cursor", cursorPlugin) + + taskData := &tasks.CursorTaskData{ + Options: &tasks.CursorOptions{ + ConnectionId: 1, + ScopeId: "team", + }, + Connection: &models.CursorConnection{ + CursorConn: models.CursorConn{ + RestConnection: helper.RestConnection{Endpoint: models.DefaultEndpoint}, + KeyTier: models.KeyTierEnterprise, + }, + }, + } + + dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_cursor_ai_code_commit_details.csv", "_raw_cursor_ai_code_commit_details") + dataflowTester.FlushTabler(&models.CursorAiCodeConversation{}) + dataflowTester.FlushTabler(&models.CursorAiCodeRangeAnnotation{}) + + dataflowTester.Subtask(tasks.ExtractAiCodeCommitDetailsMeta, taskData) + + var count int64 + if err := dataflowTester.Db.Model(&models.CursorAiCodeRangeAnnotation{}).Count(&count).Error; err != nil { + t.Fatalf("count range annotations: %v", err) + } + if count != 0 { + t.Fatalf("expected 0 range annotations without commit-details access, got %d", count) + } +} diff --git a/backend/plugins/cursor/e2e/ai_code_extractors_test.go b/backend/plugins/cursor/e2e/ai_code_extractors_test.go new file mode 100644 index 00000000000..eb6c141fdfe --- /dev/null +++ b/backend/plugins/cursor/e2e/ai_code_extractors_test.go @@ -0,0 +1,118 @@ +/* +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 e2e + +import ( + "testing" + "time" + + "github.com/apache/devlake/core/config" + "github.com/apache/devlake/core/models/common" + "github.com/apache/devlake/core/runner" + "github.com/apache/devlake/helpers/e2ehelper" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/cursor/impl" + "github.com/apache/devlake/plugins/cursor/models" + "github.com/apache/devlake/plugins/cursor/tasks" +) + +func TestCursorAiCodeExtractorsDataFlow(t *testing.T) { + cfg := config.GetConfig() + dbUrl := cfg.GetString("E2E_DB_URL") + if dbUrl == "" { + t.Skip("skipping e2e test: E2E_DB_URL is not set") + } + if err := runner.CheckDbConnection(dbUrl, 10*time.Second); err != nil { + t.Skipf("skipping e2e test: cannot connect to E2E_DB_URL: %v", err) + } + + var cursorPlugin impl.Cursor + dataflowTester := e2ehelper.NewDataFlowTester(t, "cursor", cursorPlugin) + + taskData := &tasks.CursorTaskData{ + Options: &tasks.CursorOptions{ + ConnectionId: 1, + ScopeId: "team", + }, + Connection: &models.CursorConnection{ + CursorConn: models.CursorConn{ + RestConnection: helper.RestConnection{Endpoint: models.DefaultEndpoint}, + KeyTier: models.KeyTierEnterprise, + }, + }, + } + + dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_cursor_ai_code_commits.csv", "_raw_cursor_ai_code_commits") + dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_cursor_ai_code_changes.csv", "_raw_cursor_ai_code_changes") + + dataflowTester.FlushTabler(&models.CursorAiCodeCommit{}) + dataflowTester.FlushTabler(&models.CursorAiCodeChange{}) + + dataflowTester.Subtask(tasks.ExtractAiCodeCommitsMeta, taskData) + dataflowTester.Subtask(tasks.ExtractAiCodeChangesMeta, taskData) + + dataflowTester.VerifyTableWithOptions(&models.CursorAiCodeCommit{}, e2ehelper.TableOptions{ + CSVRelPath: "./snapshot_tables/_tool_cursor_ai_code_commits.csv", + IgnoreTypes: []interface{}{common.NoPKModel{}}, + }) + + dataflowTester.VerifyTableWithOptions(&models.CursorAiCodeChange{}, e2ehelper.TableOptions{ + CSVRelPath: "./snapshot_tables/_tool_cursor_ai_code_changes.csv", + IgnoreTypes: []interface{}{common.NoPKModel{}}, + }) +} + +func TestCursorAiCodeExtractorsSkipForTeamKey(t *testing.T) { + cfg := config.GetConfig() + dbUrl := cfg.GetString("E2E_DB_URL") + if dbUrl == "" { + t.Skip("skipping e2e test: E2E_DB_URL is not set") + } + if err := runner.CheckDbConnection(dbUrl, 10*time.Second); err != nil { + t.Skipf("skipping e2e test: cannot connect to E2E_DB_URL: %v", err) + } + + var cursorPlugin impl.Cursor + dataflowTester := e2ehelper.NewDataFlowTester(t, "cursor", cursorPlugin) + + taskData := &tasks.CursorTaskData{ + Options: &tasks.CursorOptions{ + ConnectionId: 1, + ScopeId: "team", + }, + Connection: &models.CursorConnection{ + CursorConn: models.CursorConn{ + RestConnection: helper.RestConnection{Endpoint: models.DefaultEndpoint}, + KeyTier: models.KeyTierTeam, + }, + }, + } + + dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_cursor_ai_code_commits.csv", "_raw_cursor_ai_code_commits") + dataflowTester.FlushTabler(&models.CursorAiCodeCommit{}) + + dataflowTester.Subtask(tasks.ExtractAiCodeCommitsMeta, taskData) + + var count int64 + if err := dataflowTester.Db.Model(&models.CursorAiCodeCommit{}).Count(&count).Error; err != nil { + t.Fatalf("count commits: %v", err) + } + if count != 0 { + t.Fatalf("expected 0 commits for team key tier, got %d", count) + } +} diff --git a/backend/plugins/cursor/e2e/bugbot_extractors_test.go b/backend/plugins/cursor/e2e/bugbot_extractors_test.go new file mode 100644 index 00000000000..a2f916e16a5 --- /dev/null +++ b/backend/plugins/cursor/e2e/bugbot_extractors_test.go @@ -0,0 +1,115 @@ +/* +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 e2e + +import ( + "testing" + "time" + + "github.com/apache/devlake/core/config" + "github.com/apache/devlake/core/models/common" + "github.com/apache/devlake/core/runner" + "github.com/apache/devlake/helpers/e2ehelper" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/cursor/impl" + "github.com/apache/devlake/plugins/cursor/models" + "github.com/apache/devlake/plugins/cursor/tasks" +) + +func TestCursorBugbotExtractorsDataFlow(t *testing.T) { + cfg := config.GetConfig() + dbUrl := cfg.GetString("E2E_DB_URL") + if dbUrl == "" { + t.Skip("skipping e2e test: E2E_DB_URL is not set") + } + if err := runner.CheckDbConnection(dbUrl, 10*time.Second); err != nil { + t.Skipf("skipping e2e test: cannot connect to E2E_DB_URL: %v", err) + } + + var cursorPlugin impl.Cursor + dataflowTester := e2ehelper.NewDataFlowTester(t, "cursor", cursorPlugin) + + taskData := &tasks.CursorTaskData{ + Options: &tasks.CursorOptions{ + ConnectionId: 1, + ScopeId: "team", + }, + Connection: &models.CursorConnection{ + CursorConn: models.CursorConn{ + RestConnection: helper.RestConnection{Endpoint: models.DefaultEndpoint}, + HasBugbotReviews: true, + }, + }, + } + + dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_cursor_bugbot_reviews.csv", "_raw_cursor_bugbot_reviews") + dataflowTester.FlushTabler(&models.CursorBugBotReview{}) + dataflowTester.FlushTabler(&models.CursorBugBotFinding{}) + + dataflowTester.Subtask(tasks.ExtractBugbotReviewsMeta, taskData) + + dataflowTester.VerifyTableWithOptions(&models.CursorBugBotReview{}, e2ehelper.TableOptions{ + CSVRelPath: "./snapshot_tables/_tool_cursor_bugbot_reviews.csv", + IgnoreTypes: []interface{}{common.NoPKModel{}}, + }) + + dataflowTester.VerifyTableWithOptions(&models.CursorBugBotFinding{}, e2ehelper.TableOptions{ + CSVRelPath: "./snapshot_tables/_tool_cursor_bugbot_findings.csv", + IgnoreTypes: []interface{}{common.NoPKModel{}}, + }) +} + +func TestCursorBugbotExtractorsSkipWithoutAccess(t *testing.T) { + cfg := config.GetConfig() + dbUrl := cfg.GetString("E2E_DB_URL") + if dbUrl == "" { + t.Skip("skipping e2e test: E2E_DB_URL is not set") + } + if err := runner.CheckDbConnection(dbUrl, 10*time.Second); err != nil { + t.Skipf("skipping e2e test: cannot connect to E2E_DB_URL: %v", err) + } + + var cursorPlugin impl.Cursor + dataflowTester := e2ehelper.NewDataFlowTester(t, "cursor", cursorPlugin) + + taskData := &tasks.CursorTaskData{ + Options: &tasks.CursorOptions{ + ConnectionId: 1, + ScopeId: "team", + }, + Connection: &models.CursorConnection{ + CursorConn: models.CursorConn{ + RestConnection: helper.RestConnection{Endpoint: models.DefaultEndpoint}, + HasBugbotReviews: false, + }, + }, + } + + dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_cursor_bugbot_reviews.csv", "_raw_cursor_bugbot_reviews") + dataflowTester.FlushTabler(&models.CursorBugBotReview{}) + + dataflowTester.Subtask(tasks.ExtractBugbotReviewsMeta, taskData) + + var count int64 + if err := dataflowTester.Db.Model(&models.CursorBugBotReview{}).Count(&count).Error; err != nil { + t.Fatalf("count reviews: %v", err) + } + if count != 0 { + t.Fatalf("expected 0 reviews without bugbot access, got %d", count) + } +} diff --git a/backend/plugins/cursor/e2e/conversation_insights_extractors_test.go b/backend/plugins/cursor/e2e/conversation_insights_extractors_test.go new file mode 100644 index 00000000000..7a3d5b57122 --- /dev/null +++ b/backend/plugins/cursor/e2e/conversation_insights_extractors_test.go @@ -0,0 +1,123 @@ +/* +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 e2e + +import ( + "testing" + "time" + + "github.com/apache/devlake/core/config" + "github.com/apache/devlake/core/runner" + "github.com/apache/devlake/helpers/e2ehelper" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/cursor/impl" + "github.com/apache/devlake/plugins/cursor/models" + "github.com/apache/devlake/plugins/cursor/tasks" +) + +func TestCursorConversationInsightsExtractorsDataFlow(t *testing.T) { + cfg := config.GetConfig() + dbUrl := cfg.GetString("E2E_DB_URL") + if dbUrl == "" { + t.Skip("skipping e2e test: E2E_DB_URL is not set") + } + if err := runner.CheckDbConnection(dbUrl, 10*time.Second); err != nil { + t.Skipf("skipping e2e test: cannot connect to E2E_DB_URL: %v", err) + } + + var cursorPlugin impl.Cursor + dataflowTester := e2ehelper.NewDataFlowTester(t, "cursor", cursorPlugin) + + taskData := &tasks.CursorTaskData{ + Options: &tasks.CursorOptions{ + ConnectionId: 1, + ScopeId: "team", + }, + Connection: &models.CursorConnection{ + CursorConn: models.CursorConn{ + RestConnection: helper.RestConnection{Endpoint: models.DefaultEndpoint}, + KeyTier: models.KeyTierEnterprise, + HasConversationInsights: true, + }, + }, + } + + dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_cursor_conversation_insights.csv", "_raw_cursor_conversation_insights") + dataflowTester.FlushTabler(&models.CursorConversationInsight{}) + + dataflowTester.Subtask(tasks.ExtractConversationInsightsMeta, taskData) + + var count int64 + if err := dataflowTester.Db.Model(&models.CursorConversationInsight{}).Count(&count).Error; err != nil { + t.Fatalf("count insights: %v", err) + } + if count < 10 { + t.Fatalf("expected at least 10 conversation insight rows, got %d", count) + } + + var intentCount int64 + if err := dataflowTester.Db.Model(&models.CursorConversationInsight{}). + Where("insight_type = ? AND dimension_value = ?", models.ConversationInsightTypeIntents, "Write Code"). + Count(&intentCount).Error; err != nil { + t.Fatalf("count intent rows: %v", err) + } + if intentCount == 0 { + t.Fatal("expected Write Code intent rows") + } +} + +func TestCursorConversationInsightsExtractorsSkipWithoutAccess(t *testing.T) { + cfg := config.GetConfig() + dbUrl := cfg.GetString("E2E_DB_URL") + if dbUrl == "" { + t.Skip("skipping e2e test: E2E_DB_URL is not set") + } + if err := runner.CheckDbConnection(dbUrl, 10*time.Second); err != nil { + t.Skipf("skipping e2e test: cannot connect to E2E_DB_URL: %v", err) + } + + var cursorPlugin impl.Cursor + dataflowTester := e2ehelper.NewDataFlowTester(t, "cursor", cursorPlugin) + + taskData := &tasks.CursorTaskData{ + Options: &tasks.CursorOptions{ + ConnectionId: 1, + ScopeId: "team", + }, + Connection: &models.CursorConnection{ + CursorConn: models.CursorConn{ + RestConnection: helper.RestConnection{Endpoint: models.DefaultEndpoint}, + KeyTier: models.KeyTierEnterprise, + HasConversationInsights: false, + }, + }, + } + + dataflowTester.ImportCsvIntoRawTable("./raw_tables/_raw_cursor_conversation_insights.csv", "_raw_cursor_conversation_insights") + dataflowTester.FlushTabler(&models.CursorConversationInsight{}) + + dataflowTester.Subtask(tasks.ExtractConversationInsightsMeta, taskData) + + var count int64 + if err := dataflowTester.Db.Model(&models.CursorConversationInsight{}).Count(&count).Error; err != nil { + t.Fatalf("count insights: %v", err) + } + if count != 0 { + t.Fatalf("expected 0 insights without conversation insights access, got %d", count) + } +} diff --git a/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_ai_code_changes.csv b/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_ai_code_changes.csv new file mode 100644 index 00000000000..7416bcab24b --- /dev/null +++ b/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_ai_code_changes.csv @@ -0,0 +1,2 @@ +id,params,data,url,input,created_at +1,"{""ConnectionId"":1,""ScopeId"":""team"",""Endpoint"":""https://api.cursor.com""}","{""changeId"":""chg_001"",""userId"":""user_ent123"",""userEmail"":""dev@example.com"",""source"":""TAB"",""model"":""default"",""totalLinesAdded"":12,""totalLinesDeleted"":1,""createdAt"":""2026-08-20T15:00:00Z"",""metadata"":[{""fileName"":""main.go"",""lineStart"":10,""lineEnd"":15}]}",https://api.cursor.com/analytics/ai-code/changes,null,2026-08-20 15:00:00.000 diff --git a/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_ai_code_commit_details.csv b/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_ai_code_commit_details.csv new file mode 100644 index 00000000000..4331b8aefdb --- /dev/null +++ b/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_ai_code_commit_details.csv @@ -0,0 +1,2 @@ +id,params,data,url,input,created_at +1,"{""ConnectionId"":1,""ScopeId"":""team"",""Endpoint"":""https://api.cursor.com""}","{""commits"":[{""commitHash"":""abc123def456"",""commitSource"":""ide"",""rangeAnnotations"":[{""filePath"":""backend/plugins/cursor/tasks/ai_code_commits_collector.go"",""groups"":[{""conversationId"":""846952e0-0221-473b-bb1d-e5d016f071b1"",""model"":""composer-2.5-fast"",""operationType"":""insert"",""ranges"":[{""start"":10,""end"":25},{""start"":42,""end"":58}]}]}]}],""conversations"":[{""id"":""846952e0-0221-473b-bb1d-e5d016f071b1"",""title"":""Refactor analytics module"",""tldr"":""Extracted report generation into separate functions"",""overview"":""Refactored the analytics module to improve maintainability."",""summaryBullets"":[""Created dedicated report generator class"",""Added unit tests for new functions""]}]}",https://api.cursor.com/analytics/ai-code/commits/abc123def456,null,2026-08-20 14:35:00.000 diff --git a/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_ai_code_commits.csv b/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_ai_code_commits.csv new file mode 100644 index 00000000000..a315f0a41d5 --- /dev/null +++ b/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_ai_code_commits.csv @@ -0,0 +1,2 @@ +id,params,data,url,input,created_at +1,"{""ConnectionId"":1,""ScopeId"":""team"",""Endpoint"":""https://api.cursor.com""}","{""commitHash"":""abc123def456"",""userId"":""user_ent123"",""userEmail"":""dev@example.com"",""repoName"":""github.com/org/repo"",""branchName"":""main"",""isPrimaryBranch"":true,""commitSource"":""ide"",""totalLinesAdded"":120,""totalLinesDeleted"":10,""tabLinesAdded"":40,""tabLinesDeleted"":2,""composerLinesAdded"":50,""composerLinesDeleted"":5,""nonAiLinesAdded"":30,""nonAiLinesDeleted"":3,""commitTs"":""2026-08-20T14:30:00Z"",""createdAt"":""2026-08-20T14:31:00Z"",""message"":""Refactor: extract analytics client""}",https://api.cursor.com/analytics/ai-code/commits,null,2026-08-20 14:31:00.000 diff --git a/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_bugbot_reviews.csv b/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_bugbot_reviews.csv new file mode 100644 index 00000000000..a0957d47fab --- /dev/null +++ b/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_bugbot_reviews.csv @@ -0,0 +1,3 @@ +id,params,data,url,input,created_at +1,"{""ConnectionId"":1,""ScopeId"":""team"",""Endpoint"":""https://api.cursor.com""}","{""request_id"":""6e0d261c-86a2-4383-89f0-9162c1c10662"",""timestamp"":""2026-06-29T19:42:18.000Z"",""repo"":""github.com/your-org/your-repo"",""repo_node_id"":""R_kgDOABCDEF"",""pr_number"":42,""commit_sha"":""9f3c2a1b7d8e4f5061728394a5b6c7d8e9f0a1b2"",""bugs_found"":2,""cost_cents"":42.5,""dry_run"":false,""publication_status"":""posted"",""bugs"":[{""comment_id"":""2147483999"",""resolution_status"":""resolved"",""severity"":""high""},{""comment_id"":""2147484000"",""resolution_status"":""unresolved"",""severity"":""medium""}]}","https://api.cursor.com/analytics/team/bugbot-reviews",null,2026-06-29 19:42:18.000 +2,"{""ConnectionId"":1,""ScopeId"":""team"",""Endpoint"":""https://api.cursor.com""}","{""request_id"":""a1b2c3d4-e5f6-7890-abcd-ef1234567890"",""timestamp"":""2026-06-29T20:15:03.000Z"",""repo"":""github.com/your-org/your-repo"",""repo_node_id"":""R_kgDOABCDEF"",""pr_number"":42,""commit_sha"":""9f3c2a1b7d8e4f5061728394a5b6c7d8e9f0a1b2"",""bugs_found"":1,""cost_cents"":null,""dry_run"":true,""publication_status"":""dry_run"",""bugs"":[{""comment_id"":null,""resolution_status"":null,""severity"":""medium"",""title"":""Unbounded retry loop"",""description"":""retry() recurses without a ceiling."",""locations"":[{""file"":""src/net.ts"",""start_line"":5,""end_line"":9}]}]}","https://api.cursor.com/analytics/team/bugbot-reviews",null,2026-06-29 20:15:03.000 diff --git a/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_conversation_insights.csv b/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_conversation_insights.csv new file mode 100644 index 00000000000..e5a6ac8f9d3 --- /dev/null +++ b/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_conversation_insights.csv @@ -0,0 +1,2 @@ +id,params,data,url,input,created_at +1,"{""ConnectionId"":1,""ScopeId"":""team"",""Endpoint"":""https://api.cursor.com""}","{""data"":{""intents"":{""distribution"":[{""intent"":""Write Code"",""count"":18},{""intent"":""Ask"",""count"":7}],""topValues"":[{""intent"":""Write Code"",""count"":18}],""timeSeries"":[{""date"":""2026-03-01"",""intent"":""Ask"",""count"":2},{""date"":""2026-03-02"",""intent"":""Write Code"",""count"":6}],""subcategories"":{""askMode"":[{""subcategory"":""error_fix"",""count"":4}]}},""complexity"":{""distribution"":[{""complexity"":""high"",""count"":12}],""timeSeries"":[{""date"":""2026-03-02"",""complexity"":""high"",""count"":5}]},""categories"":{""distribution"":[{""category"":""New Features"",""count"":9}]},""guidanceLevels"":{""distribution"":[{""guidanceLevel"":""high"",""count"":8}]},""workTypes"":{""distribution"":[{""workType"":""new_feature"",""count"":9}]}},""params"":{""metric"":""conversation-insights"",""startDate"":""2026-03-01"",""endDate"":""2026-03-07""}}","https://api.cursor.com/analytics/team/conversation-insights",null,2026-03-07 00:00:00.000 diff --git a/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_user_spend.csv b/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_user_spend.csv index d4c4131147f..bc4573c3efb 100644 --- a/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_user_spend.csv +++ b/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_user_spend.csv @@ -1,2 +1,3 @@ id,params,data,url,input,created_at 1,"{""ConnectionId"":1,""ScopeId"":""team"",""Endpoint"":""https://api.cursor.com""}","{""subscriptionCycleStart"":1781472188000,""collectedAt"":""2026-07-10T17:31:24.141244Z"",""member"":{""userId"":""user_example123"",""spendCents"":3224,""includedSpendCents"":1984,""fastPremiumRequests"":496,""name"":""Example User"",""email"":""user@example.com"",""profilePictureUrl"":null,""role"":""member"",""monthlyLimitDollars"":null,""hardLimitOverrideDollars"":0}}",https://api.cursor.com/teams/spend,null,2026-07-10 17:31:24.000 +2,"{""ConnectionId"":1,""ScopeId"":""team"",""Endpoint"":""https://api.cursor.com""}","{""subscriptionCycleStart"":1785542400000,""collectedAt"":""2026-08-25T18:37:58.857027Z"",""member"":{""userId"":""user_enterprise123"",""spendCents"":0,""overallSpendCents"":1619.087826,""fastPremiumRequests"":0,""name"":""Enterprise User"",""email"":""enterprise@example.com"",""profilePictureUrl"":null,""role"":""owner"",""monthlyLimitDollars"":500,""hardLimitOverrideDollars"":0}}",https://api.cursor.com/teams/spend,null,2026-08-25 18:37:58.000 diff --git a/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_ai_code_changes.csv b/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_ai_code_changes.csv new file mode 100644 index 00000000000..8748def2ba6 --- /dev/null +++ b/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_ai_code_changes.csv @@ -0,0 +1,2 @@ +connection_id,scope_id,change_id,user_id,user_email,source,model,total_lines_added,total_lines_deleted,ingested_at,metadata +1,team,chg_001,user_ent123,dev@example.com,TAB,default,12,1,2026-08-20 15:00:00,"[{""fileName"":""main.go"",""lineStart"":10,""lineEnd"":15}]" diff --git a/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_ai_code_commits.csv b/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_ai_code_commits.csv new file mode 100644 index 00000000000..49e43d3739f --- /dev/null +++ b/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_ai_code_commits.csv @@ -0,0 +1,2 @@ +connection_id,scope_id,commit_hash,user_id,user_email,repo_name,branch_name,is_primary_branch,commit_source,total_lines_added,total_lines_deleted,tab_lines_added,tab_lines_deleted,composer_lines_added,composer_lines_deleted,non_ai_lines_added,non_ai_lines_deleted,commit_ts,ingested_at,message +1,team,abc123def456,user_ent123,dev@example.com,github.com/org/repo,main,1,ide,120,10,40,2,50,5,30,3,2026-08-20 14:30:00,2026-08-20 14:31:00,Refactor: extract analytics client diff --git a/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_ai_code_conversations.csv b/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_ai_code_conversations.csv new file mode 100644 index 00000000000..725cfed189b --- /dev/null +++ b/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_ai_code_conversations.csv @@ -0,0 +1,2 @@ +connection_id,scope_id,conversation_id,title,tldr,overview,summary_bullets +1,team,846952e0-0221-473b-bb1d-e5d016f071b1,Refactor analytics module,Extracted report generation into separate functions,Refactored the analytics module to improve maintainability.,"[""Created dedicated report generator class"",""Added unit tests for new functions""]" diff --git a/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_ai_code_range_annotations.csv b/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_ai_code_range_annotations.csv new file mode 100644 index 00000000000..7505256f987 --- /dev/null +++ b/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_ai_code_range_annotations.csv @@ -0,0 +1,3 @@ +connection_id,scope_id,annotation_id,commit_hash,file_path,conversation_id,range_start,range_end,model,operation_type,line_count +1,team,78df395abdff390575f26e99e073a4f4cecdc6c04da2072d22404db3aceb22e7,abc123def456,backend/plugins/cursor/tasks/ai_code_commits_collector.go,846952e0-0221-473b-bb1d-e5d016f071b1,10,25,composer-2.5-fast,insert,16 +1,team,7d79a3782b8e62c1ee7535d714540444800fec64513eff43b84575bfe07baa65,abc123def456,backend/plugins/cursor/tasks/ai_code_commits_collector.go,846952e0-0221-473b-bb1d-e5d016f071b1,42,58,composer-2.5-fast,insert,17 diff --git a/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_bugbot_findings.csv b/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_bugbot_findings.csv new file mode 100644 index 00000000000..17755ea2169 --- /dev/null +++ b/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_bugbot_findings.csv @@ -0,0 +1,4 @@ +connection_id,scope_id,request_id,finding_index,comment_id,resolution_status,severity,title,description,locations +1,team,6e0d261c-86a2-4383-89f0-9162c1c10662,0,2147483999,resolved,high,,, +1,team,6e0d261c-86a2-4383-89f0-9162c1c10662,1,2147484000,unresolved,medium,,, +1,team,a1b2c3d4-e5f6-7890-abcd-ef1234567890,0,,,medium,Unbounded retry loop,retry() recurses without a ceiling.,"[{""file"":""src/net.ts"",""start_line"":5,""end_line"":9}]" diff --git a/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_bugbot_reviews.csv b/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_bugbot_reviews.csv new file mode 100644 index 00000000000..760b553ea87 --- /dev/null +++ b/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_bugbot_reviews.csv @@ -0,0 +1,3 @@ +connection_id,scope_id,request_id,timestamp,repo,repo_node_id,pr_number,commit_sha,bugs_found,cost_cents,dry_run,publication_status +1,team,6e0d261c-86a2-4383-89f0-9162c1c10662,2026-06-29 19:42:18,github.com/your-org/your-repo,R_kgDOABCDEF,42,9f3c2a1b7d8e4f5061728394a5b6c7d8e9f0a1b2,2,42.5,0,posted +1,team,a1b2c3d4-e5f6-7890-abcd-ef1234567890,2026-06-29 20:15:03,github.com/your-org/your-repo,R_kgDOABCDEF,42,9f3c2a1b7d8e4f5061728394a5b6c7d8e9f0a1b2,1,,1,dry_run diff --git a/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_user_spend.csv b/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_user_spend.csv index f1a1bcdbca5..1bdb4e17bf8 100644 --- a/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_user_spend.csv +++ b/backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_user_spend.csv @@ -1,2 +1,3 @@ connection_id,scope_id,user_id,billing_cycle_start,collected_at,email,name,role,spend_cents,included_spend_cents,fast_premium_requests,monthly_limit_dollars,hard_limit_override_dollars 1,team,user_example123,2026-06-14T21:23:08.000+00:00,2026-07-10T17:31:24.141+00:00,user@example.com,Example User,member,3224,1984,496,0,0 +1,team,user_enterprise123,2026-08-01T00:00:00.000+00:00,2026-08-25T18:37:58.857+00:00,enterprise@example.com,Enterprise User,owner,0,1619.087826,0,500,0 diff --git a/backend/plugins/cursor/impl/impl.go b/backend/plugins/cursor/impl/impl.go index 04b0977338b..1f0e6289165 100644 --- a/backend/plugins/cursor/impl/impl.go +++ b/backend/plugins/cursor/impl/impl.go @@ -18,6 +18,8 @@ limitations under the License. package impl import ( + gocontext "context" + "github.com/apache/devlake/core/context" "github.com/apache/devlake/core/dal" "github.com/apache/devlake/core/errors" @@ -27,6 +29,7 @@ import ( "github.com/apache/devlake/plugins/cursor/api" "github.com/apache/devlake/plugins/cursor/models" "github.com/apache/devlake/plugins/cursor/models/migrationscripts" + "github.com/apache/devlake/plugins/cursor/service" "github.com/apache/devlake/plugins/cursor/tasks" ) @@ -92,6 +95,10 @@ func (p Cursor) PrepareTaskData(taskCtx plugin.TaskContext, options map[string]i NormalizeConnection(connection) + if err := service.RefreshAndPersistOptionalPermissions(gocontext.Background(), taskCtx, connection); err != nil { + taskCtx.GetLogger().Warn(err, "cursor: optional permission refresh failed; using stored connection capabilities") + } + return &tasks.CursorTaskData{ Options: &op, Connection: connection, diff --git a/backend/plugins/cursor/models/ai_code_change.go b/backend/plugins/cursor/models/ai_code_change.go new file mode 100644 index 00000000000..caebbaeb3af --- /dev/null +++ b/backend/plugins/cursor/models/ai_code_change.go @@ -0,0 +1,47 @@ +/* +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 models + +import ( + "time" + + "github.com/apache/devlake/core/models/common" +) + +// CursorAiCodeChange stores granular accepted AI changes from +// GET /analytics/ai-code/changes (Enterprise API). +type CursorAiCodeChange struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + ChangeId string `gorm:"primaryKey;type:varchar(255)" json:"changeId"` + + UserId string `gorm:"type:varchar(255);index" json:"userId"` + UserEmail string `gorm:"type:varchar(255);index" json:"userEmail"` + Source string `gorm:"type:varchar(20)" json:"source"` + Model string `gorm:"type:varchar(255)" json:"model"` + TotalLinesAdded int `json:"totalLinesAdded"` + TotalLinesDeleted int `json:"totalLinesDeleted"` + IngestedAt *time.Time `gorm:"column:ingested_at" json:"createdAt"` + Metadata string `gorm:"type:text" json:"metadata"` + + common.NoPKModel +} + +func (CursorAiCodeChange) TableName() string { + return "_tool_cursor_ai_code_changes" +} diff --git a/backend/plugins/cursor/models/ai_code_commit.go b/backend/plugins/cursor/models/ai_code_commit.go new file mode 100644 index 00000000000..8a9a4219816 --- /dev/null +++ b/backend/plugins/cursor/models/ai_code_commit.go @@ -0,0 +1,56 @@ +/* +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 models + +import ( + "time" + + "github.com/apache/devlake/core/models/common" +) + +// CursorAiCodeCommit stores per-commit AI line attribution from +// GET /analytics/ai-code/commits (Enterprise API). +type CursorAiCodeCommit struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + CommitHash string `gorm:"primaryKey;type:varchar(64)" json:"commitHash"` + + UserId string `gorm:"type:varchar(255);index" json:"userId"` + UserEmail string `gorm:"type:varchar(255);index" json:"userEmail"` + RepoName string `gorm:"type:varchar(255)" json:"repoName"` + BranchName string `gorm:"type:varchar(255)" json:"branchName"` + IsPrimaryBranch *bool `json:"isPrimaryBranch"` + CommitSource string `gorm:"type:varchar(20)" json:"commitSource"` + TotalLinesAdded int `json:"totalLinesAdded"` + TotalLinesDeleted int `json:"totalLinesDeleted"` + TabLinesAdded int `json:"tabLinesAdded"` + TabLinesDeleted int `json:"tabLinesDeleted"` + ComposerLinesAdded int `json:"composerLinesAdded"` + ComposerLinesDeleted int `json:"composerLinesDeleted"` + NonAiLinesAdded *int `json:"nonAiLinesAdded"` + NonAiLinesDeleted *int `json:"nonAiLinesDeleted"` + CommitTs *time.Time `json:"commitTs"` + IngestedAt *time.Time `gorm:"column:ingested_at" json:"createdAt"` + Message string `gorm:"type:text" json:"message"` + + common.NoPKModel +} + +func (CursorAiCodeCommit) TableName() string { + return "_tool_cursor_ai_code_commits" +} diff --git a/backend/plugins/cursor/models/ai_code_conversation.go b/backend/plugins/cursor/models/ai_code_conversation.go new file mode 100644 index 00000000000..f5f1cdd7bad --- /dev/null +++ b/backend/plugins/cursor/models/ai_code_conversation.go @@ -0,0 +1,41 @@ +/* +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 models + +import ( + "github.com/apache/devlake/core/models/common" +) + +// CursorAiCodeConversation stores conversation metadata referenced by commit-detail +// range annotations from GET /analytics/ai-code/commits/:commitHash (Enterprise API). +type CursorAiCodeConversation struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + ConversationId string `gorm:"primaryKey;type:varchar(255)" json:"conversationId"` + + Title string `gorm:"type:varchar(512)" json:"title"` + Tldr string `gorm:"type:text" json:"tldr"` + Overview string `gorm:"type:text" json:"overview"` + SummaryBullets string `gorm:"type:text" json:"summaryBullets"` + + common.NoPKModel +} + +func (CursorAiCodeConversation) TableName() string { + return "_tool_cursor_ai_code_conversations" +} diff --git a/backend/plugins/cursor/models/ai_code_range_annotation.go b/backend/plugins/cursor/models/ai_code_range_annotation.go new file mode 100644 index 00000000000..975677a7e3d --- /dev/null +++ b/backend/plugins/cursor/models/ai_code_range_annotation.go @@ -0,0 +1,45 @@ +/* +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 models + +import ( + "github.com/apache/devlake/core/models/common" +) + +// CursorAiCodeRangeAnnotation stores flattened file-level blame rows from commit-detail +// rangeAnnotations (GET /analytics/ai-code/commits/:commitHash). +type CursorAiCodeRangeAnnotation struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + AnnotationId string `gorm:"primaryKey;type:varchar(64)" json:"annotationId"` + + CommitHash string `gorm:"type:varchar(64);index" json:"commitHash"` + FilePath string `gorm:"type:varchar(1024)" json:"filePath"` + ConversationId string `gorm:"type:varchar(255);index" json:"conversationId"` + RangeStart int `json:"rangeStart"` + RangeEnd int `json:"rangeEnd"` + Model string `gorm:"type:varchar(255)" json:"model"` + OperationType string `gorm:"type:varchar(64)" json:"operationType"` + LineCount int `json:"lineCount"` + + common.NoPKModel +} + +func (CursorAiCodeRangeAnnotation) TableName() string { + return "_tool_cursor_ai_code_range_annotations" +} diff --git a/backend/plugins/cursor/models/bugbot_finding.go b/backend/plugins/cursor/models/bugbot_finding.go new file mode 100644 index 00000000000..38e75b3339c --- /dev/null +++ b/backend/plugins/cursor/models/bugbot_finding.go @@ -0,0 +1,43 @@ +/* +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 models + +import ( + "github.com/apache/devlake/core/models/common" +) + +// CursorBugBotFinding stores one finding within a BugBot review (normalized from bugs[]). +type CursorBugBotFinding struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + RequestId string `gorm:"primaryKey;type:varchar(64)" json:"requestId"` + FindingIndex int `gorm:"primaryKey" json:"findingIndex"` + + CommentId string `gorm:"type:varchar(64)" json:"commentId"` + ResolutionStatus string `gorm:"type:varchar(20)" json:"resolutionStatus"` + Severity string `gorm:"type:varchar(20);index" json:"severity"` + Title string `gorm:"type:varchar(500)" json:"title"` + Description string `gorm:"type:text" json:"description"` + Locations string `gorm:"type:text" json:"locations"` + + common.NoPKModel +} + +func (CursorBugBotFinding) TableName() string { + return "_tool_cursor_bugbot_findings" +} diff --git a/backend/plugins/cursor/models/bugbot_review.go b/backend/plugins/cursor/models/bugbot_review.go new file mode 100644 index 00000000000..08cc221dd3b --- /dev/null +++ b/backend/plugins/cursor/models/bugbot_review.go @@ -0,0 +1,48 @@ +/* +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 models + +import ( + "time" + + "github.com/apache/devlake/core/models/common" +) + +// CursorBugBotReview stores one completed BugBot review from +// GET /analytics/team/bugbot-reviews. +type CursorBugBotReview struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + RequestId string `gorm:"primaryKey;type:varchar(64)" json:"requestId"` + + Timestamp *time.Time `json:"timestamp"` + Repo string `gorm:"type:varchar(255);index" json:"repo"` + RepoNodeId string `gorm:"type:varchar(255)" json:"repoNodeId"` + PrNumber *int `json:"prNumber"` + CommitSha string `gorm:"type:varchar(64)" json:"commitSha"` + BugsFound int `json:"bugsFound"` + CostCents *float64 `json:"costCents"` + DryRun bool `json:"dryRun"` + PublicationStatus string `gorm:"type:varchar(20)" json:"publicationStatus"` + + common.NoPKModel +} + +func (CursorBugBotReview) TableName() string { + return "_tool_cursor_bugbot_reviews" +} diff --git a/backend/plugins/cursor/models/connection.go b/backend/plugins/cursor/models/connection.go index 21e19a4986b..c961986ec06 100644 --- a/backend/plugins/cursor/models/connection.go +++ b/backend/plugins/cursor/models/connection.go @@ -33,11 +33,24 @@ const ( DefaultRateLimitPerHour = 1200 ) -// CursorConn stores Cursor Team Admin API connection settings. +const ( + // KeyTierPersonal indicates a User API key (cannot access team endpoints). + KeyTierPersonal = "personal" + // KeyTierTeam indicates a Team Admin API key (admin:* scope, no analytics). + KeyTierTeam = "team" + // KeyTierEnterprise indicates an Enterprise Admin API key (admin:*, analytics, AI code tracking). + KeyTierEnterprise = "enterprise" +) + +// CursorConn stores Cursor Admin API connection settings. type CursorConn struct { helper.RestConnection `mapstructure:",squash"` - Token string `mapstructure:"token" json:"token"` + Token string `mapstructure:"token" json:"token"` + KeyTier string `mapstructure:"keyTier" json:"keyTier" gorm:"type:varchar(20)"` + HasBugbotReviews bool `mapstructure:"hasBugbotReviews" json:"hasBugbotReviews"` + HasConversationInsights bool `mapstructure:"hasConversationInsights" json:"hasConversationInsights"` + HasAiCodeCommitDetails bool `mapstructure:"hasAiCodeCommitDetails" json:"hasAiCodeCommitDetails"` } // SetupAuthentication uses HTTP Basic auth with the API key as username and an empty password. @@ -87,6 +100,9 @@ func (connection *CursorConnection) Normalize() { if connection.RateLimitPerHour <= 0 { connection.RateLimitPerHour = DefaultRateLimitPerHour } + if connection.KeyTier == "" { + connection.KeyTier = KeyTierTeam + } } func (connection *CursorConnection) MergeFromRequest(target *CursorConnection, body map[string]interface{}) error { diff --git a/backend/plugins/cursor/models/conversation_insight.go b/backend/plugins/cursor/models/conversation_insight.go new file mode 100644 index 00000000000..436cbfc7235 --- /dev/null +++ b/backend/plugins/cursor/models/conversation_insight.go @@ -0,0 +1,60 @@ +/* +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 models + +import ( + "time" + + "github.com/apache/devlake/core/models/common" +) + +const ( + ConversationInsightTypeIntents = "intents" + ConversationInsightTypeComplexity = "complexity" + ConversationInsightTypeCategories = "categories" + ConversationInsightTypeGuidanceLevels = "guidanceLevels" + ConversationInsightTypeWorkTypes = "workTypes" + ConversationInsightTypeSubcategory = "subcategory" + + ConversationInsightAggDistribution = "distribution" + ConversationInsightAggTopValues = "topValues" + ConversationInsightAggTimeSeries = "timeSeries" + ConversationInsightAggSubcategory = "subcategory" +) + +// CursorConversationInsight stores flattened Conversation Insights metrics from +// GET /analytics/team/conversation-insights (Enterprise Analytics API). +type CursorConversationInsight struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + RecordId string `gorm:"primaryKey;type:varchar(64)" json:"recordId"` + PeriodStart time.Time `gorm:"index" json:"periodStart"` + PeriodEnd time.Time `gorm:"index" json:"periodEnd"` + InsightType string `gorm:"type:varchar(32);index" json:"insightType"` + AggregationType string `gorm:"type:varchar(32);index" json:"aggregationType"` + DimensionValue string `gorm:"type:varchar(255);index" json:"dimensionValue"` + SubcategoryGroup string `gorm:"type:varchar(32);index" json:"subcategoryGroup"` + MetricDate *time.Time `gorm:"index" json:"metricDate"` + Count int `json:"count"` + + common.NoPKModel +} + +func (CursorConversationInsight) TableName() string { + return "_tool_cursor_conversation_insights" +} diff --git a/backend/plugins/cursor/models/migrationscripts/20260826_add_key_tier.go b/backend/plugins/cursor/models/migrationscripts/20260826_add_key_tier.go new file mode 100644 index 00000000000..414c66c6149 --- /dev/null +++ b/backend/plugins/cursor/models/migrationscripts/20260826_add_key_tier.go @@ -0,0 +1,99 @@ +/* +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 migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/migrationscripts/archived" + "github.com/apache/devlake/helpers/migrationhelper" + "github.com/apache/devlake/plugins/cursor/models" +) + +type addCursorKeyTier struct{} + +type cursorConnection20260826 struct { + KeyTier string `gorm:"type:varchar(20)"` +} + +func (cursorConnection20260826) TableName() string { return "_tool_cursor_connections" } + +type cursorAiCodeCommit20260826 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + CommitHash string `gorm:"primaryKey;type:varchar(64)"` + UserId string `gorm:"type:varchar(255);index"` + UserEmail string `gorm:"type:varchar(255);index"` + RepoName string `gorm:"type:varchar(255)"` + BranchName string `gorm:"type:varchar(255)"` + IsPrimaryBranch *bool + CommitSource string `gorm:"type:varchar(20)"` + TotalLinesAdded int + TotalLinesDeleted int + TabLinesAdded int + TabLinesDeleted int + ComposerLinesAdded int + ComposerLinesDeleted int + NonAiLinesAdded *int + NonAiLinesDeleted *int + CommitTs *time.Time + CreatedAt *time.Time + archived.NoPKModel +} + +func (cursorAiCodeCommit20260826) TableName() string { return "_tool_cursor_ai_code_commits" } + +type cursorAiCodeChange20260826 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + ChangeId string `gorm:"primaryKey;type:varchar(255)"` + UserId string `gorm:"type:varchar(255);index"` + UserEmail string `gorm:"type:varchar(255);index"` + Source string `gorm:"type:varchar(20)"` + Model string `gorm:"type:varchar(255)"` + TotalLinesAdded int + TotalLinesDeleted int + CreatedAt *time.Time + Metadata string `gorm:"type:text"` + archived.NoPKModel +} + +func (cursorAiCodeChange20260826) TableName() string { return "_tool_cursor_ai_code_changes" } + +func (*addCursorKeyTier) Up(basicRes context.BasicRes) errors.Error { + if err := migrationhelper.AutoMigrateTables( + basicRes, + &cursorConnection20260826{}, + &cursorAiCodeCommit20260826{}, + &cursorAiCodeChange20260826{}, + ); err != nil { + return err + } + return basicRes.GetDal().Exec( + "UPDATE _tool_cursor_connections SET key_tier = ? WHERE key_tier IS NULL OR key_tier = ''", + models.KeyTierTeam, + ) +} + +func (*addCursorKeyTier) Version() uint64 { return 20260826120000 } + +func (*addCursorKeyTier) Name() string { + return "cursor add key_tier, ai_code_commits, and ai_code_changes tables" +} diff --git a/backend/plugins/cursor/models/migrationscripts/20260827_fix_ai_code_schema.go b/backend/plugins/cursor/models/migrationscripts/20260827_fix_ai_code_schema.go new file mode 100644 index 00000000000..cc67e18a194 --- /dev/null +++ b/backend/plugins/cursor/models/migrationscripts/20260827_fix_ai_code_schema.go @@ -0,0 +1,87 @@ +/* +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 migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/migrationscripts/archived" + "github.com/apache/devlake/helpers/migrationhelper" +) + +type fixCursorAiCodeSchema struct{} + +// cursorAiCodeCommit20260827 uses ingested_at for the API ingestion timestamp so +// archived.NoPKModel created_at/updated_at do not collide during AutoMigrate. +type cursorAiCodeCommit20260827 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + CommitHash string `gorm:"primaryKey;type:varchar(64)"` + UserId string `gorm:"type:varchar(255);index"` + UserEmail string `gorm:"type:varchar(255);index"` + RepoName string `gorm:"type:varchar(255)"` + BranchName string `gorm:"type:varchar(255)"` + IsPrimaryBranch *bool + CommitSource string `gorm:"type:varchar(20)"` + TotalLinesAdded int + TotalLinesDeleted int + TabLinesAdded int + TabLinesDeleted int + ComposerLinesAdded int + ComposerLinesDeleted int + NonAiLinesAdded *int + NonAiLinesDeleted *int + CommitTs *time.Time + IngestedAt *time.Time `gorm:"column:ingested_at"` + archived.NoPKModel +} + +func (cursorAiCodeCommit20260827) TableName() string { return "_tool_cursor_ai_code_commits" } + +type cursorAiCodeChange20260827 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + ChangeId string `gorm:"primaryKey;type:varchar(255)"` + UserId string `gorm:"type:varchar(255);index"` + UserEmail string `gorm:"type:varchar(255);index"` + Source string `gorm:"type:varchar(20)"` + Model string `gorm:"type:varchar(255)"` + TotalLinesAdded int + TotalLinesDeleted int + IngestedAt *time.Time `gorm:"column:ingested_at"` + Metadata string `gorm:"type:text"` + archived.NoPKModel +} + +func (cursorAiCodeChange20260827) TableName() string { return "_tool_cursor_ai_code_changes" } + +func (*fixCursorAiCodeSchema) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &cursorAiCodeCommit20260827{}, + &cursorAiCodeChange20260827{}, + ) +} + +func (*fixCursorAiCodeSchema) Version() uint64 { return 20260827120000 } + +func (*fixCursorAiCodeSchema) Name() string { + return "cursor fix ai_code_commits and ai_code_changes schema" +} diff --git a/backend/plugins/cursor/models/migrationscripts/20260901_add_conversation_insights.go b/backend/plugins/cursor/models/migrationscripts/20260901_add_conversation_insights.go new file mode 100644 index 00000000000..edbc8e596d9 --- /dev/null +++ b/backend/plugins/cursor/models/migrationscripts/20260901_add_conversation_insights.go @@ -0,0 +1,68 @@ +/* +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 migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/migrationscripts/archived" + "github.com/apache/devlake/helpers/migrationhelper" +) + +type addCursorConversationInsights struct{} + +type cursorConnection20260901 struct { + HasConversationInsights bool +} + +func (cursorConnection20260901) TableName() string { return "_tool_cursor_connections" } + +type cursorConversationInsight20260901 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + RecordId string `gorm:"primaryKey;type:varchar(64)"` + PeriodStart time.Time `gorm:"index"` + PeriodEnd time.Time `gorm:"index"` + InsightType string `gorm:"type:varchar(32);index"` + AggregationType string `gorm:"type:varchar(32);index"` + DimensionValue string `gorm:"type:varchar(255);index"` + SubcategoryGroup string `gorm:"type:varchar(32);index"` + MetricDate *time.Time `gorm:"index"` + Count int + archived.NoPKModel +} + +func (cursorConversationInsight20260901) TableName() string { + return "_tool_cursor_conversation_insights" +} + +func (*addCursorConversationInsights) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &cursorConnection20260901{}, + &cursorConversationInsight20260901{}, + ) +} + +func (*addCursorConversationInsights) Version() uint64 { return 20260903120000 } + +func (*addCursorConversationInsights) Name() string { + return "cursor add conversation insights table and has_conversation_insights column" +} diff --git a/backend/plugins/cursor/models/migrationscripts/20260902_add_bugbot_reviews.go b/backend/plugins/cursor/models/migrationscripts/20260902_add_bugbot_reviews.go new file mode 100644 index 00000000000..47dae47992b --- /dev/null +++ b/backend/plugins/cursor/models/migrationscripts/20260902_add_bugbot_reviews.go @@ -0,0 +1,84 @@ +/* +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 migrationscripts + +import ( + "time" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/migrationscripts/archived" + "github.com/apache/devlake/helpers/migrationhelper" +) + +type addCursorBugbotReviews struct{} + +type cursorConnection20260902 struct { + HasBugbotReviews bool +} + +func (cursorConnection20260902) TableName() string { return "_tool_cursor_connections" } + +type cursorBugBotReview20260902 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + RequestId string `gorm:"primaryKey;type:varchar(64)"` + Timestamp *time.Time + Repo string `gorm:"type:varchar(255);index"` + RepoNodeId string `gorm:"type:varchar(255)"` + PrNumber *int + CommitSha string `gorm:"type:varchar(64)"` + BugsFound int + CostCents *float64 + DryRun bool + PublicationStatus string `gorm:"type:varchar(20)"` + archived.NoPKModel +} + +func (cursorBugBotReview20260902) TableName() string { return "_tool_cursor_bugbot_reviews" } + +type cursorBugBotFinding20260902 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + RequestId string `gorm:"primaryKey;type:varchar(64)"` + FindingIndex int `gorm:"primaryKey"` + CommentId string `gorm:"type:varchar(64)"` + ResolutionStatus string `gorm:"type:varchar(20)"` + Severity string `gorm:"type:varchar(20);index"` + Title string `gorm:"type:varchar(500)"` + Description string `gorm:"type:text"` + Locations string `gorm:"type:text"` + archived.NoPKModel +} + +func (cursorBugBotFinding20260902) TableName() string { return "_tool_cursor_bugbot_findings" } + +func (*addCursorBugbotReviews) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &cursorConnection20260902{}, + &cursorBugBotReview20260902{}, + &cursorBugBotFinding20260902{}, + ) +} + +func (*addCursorBugbotReviews) Version() uint64 { return 20260902120000 } + +func (*addCursorBugbotReviews) Name() string { + return "cursor add bugbot review analytics tables and has_bugbot_reviews column" +} diff --git a/backend/plugins/cursor/models/migrationscripts/20260917_add_ai_code_commit_details.go b/backend/plugins/cursor/models/migrationscripts/20260917_add_ai_code_commit_details.go new file mode 100644 index 00000000000..1e7139b7ec9 --- /dev/null +++ b/backend/plugins/cursor/models/migrationscripts/20260917_add_ai_code_commit_details.go @@ -0,0 +1,104 @@ +/* +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 migrationscripts + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/migrationscripts/archived" + "github.com/apache/devlake/helpers/migrationhelper" +) + +type addCursorAiCodeCommitDetails struct{} + +type cursorConnection20260917 struct { + HasAiCodeCommitDetails bool +} + +func (cursorConnection20260917) TableName() string { return "_tool_cursor_connections" } + +type cursorAiCodeCommit20260917 struct { + Message string `gorm:"type:text"` +} + +func (cursorAiCodeCommit20260917) TableName() string { return "_tool_cursor_ai_code_commits" } + +// Snapshot of the tool tables. Migration scripts must not import the live models +// package; later model edits would change the behavior of a script that already shipped. +type cursorAiCodeConversation20260917 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + ConversationId string `gorm:"primaryKey;type:varchar(64)"` + + Title string `gorm:"type:varchar(512)"` + Tldr string `gorm:"type:text"` + Overview string `gorm:"type:text"` + SummaryBullets string `gorm:"type:text"` + + archived.NoPKModel +} + +func (cursorAiCodeConversation20260917) TableName() string { + return "_tool_cursor_ai_code_conversations" +} + +type cursorAiCodeRangeAnnotation20260917 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + AnnotationId string `gorm:"primaryKey;type:varchar(64)"` + + CommitHash string `gorm:"type:varchar(64);index"` + FilePath string `gorm:"type:varchar(1024)"` + ConversationId string `gorm:"type:varchar(64);index"` + RangeStart int + RangeEnd int + Model string `gorm:"type:varchar(255)"` + OperationType string `gorm:"type:varchar(64)"` + LineCount int + + archived.NoPKModel +} + +func (cursorAiCodeRangeAnnotation20260917) TableName() string { + return "_tool_cursor_ai_code_range_annotations" +} + +func (*addCursorAiCodeCommitDetails) Up(basicRes context.BasicRes) errors.Error { + // A failed create of the earlier wide primary key can leave a table that + // AutoMigrate cannot alter. Drop only that broken shape. + db := basicRes.GetDal() + table := cursorAiCodeRangeAnnotation20260917{}.TableName() + if db.HasTable(table) && !db.HasColumn(table, "annotation_id") { + if err := db.DropTables(table); err != nil { + return err + } + } + return migrationhelper.AutoMigrateTables( + basicRes, + &cursorConnection20260917{}, + &cursorAiCodeCommit20260917{}, + &cursorAiCodeConversation20260917{}, + &cursorAiCodeRangeAnnotation20260917{}, + ) +} + +func (*addCursorAiCodeCommitDetails) Version() uint64 { return 20260917120000 } + +func (*addCursorAiCodeCommitDetails) Name() string { + return "cursor add ai code commit details tables and has_ai_code_commit_details column" +} diff --git a/backend/plugins/cursor/models/migrationscripts/20260930_ensure_ai_code_range_annotations.go b/backend/plugins/cursor/models/migrationscripts/20260930_ensure_ai_code_range_annotations.go new file mode 100644 index 00000000000..f210769c140 --- /dev/null +++ b/backend/plugins/cursor/models/migrationscripts/20260930_ensure_ai_code_range_annotations.go @@ -0,0 +1,81 @@ +/* +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 migrationscripts + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/models/migrationscripts/archived" + "github.com/apache/devlake/helpers/migrationhelper" +) + +type ensureCursorAiCodeRangeAnnotations struct{} + +// Databases that already recorded 20260917120000 skip that script. If that run +// did not leave the tool tables behind, this script creates them. +type cursorAiCodeConversation20260930 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + ConversationId string `gorm:"primaryKey;type:varchar(64)"` + + Title string `gorm:"type:varchar(512)"` + Tldr string `gorm:"type:text"` + Overview string `gorm:"type:text"` + SummaryBullets string `gorm:"type:text"` + + archived.NoPKModel +} + +func (cursorAiCodeConversation20260930) TableName() string { + return "_tool_cursor_ai_code_conversations" +} + +type cursorAiCodeRangeAnnotation20260930 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + AnnotationId string `gorm:"primaryKey;type:varchar(64)"` + + CommitHash string `gorm:"type:varchar(64);index"` + FilePath string `gorm:"type:varchar(1024)"` + ConversationId string `gorm:"type:varchar(64);index"` + RangeStart int + RangeEnd int + Model string `gorm:"type:varchar(255)"` + OperationType string `gorm:"type:varchar(64)"` + LineCount int + + archived.NoPKModel +} + +func (cursorAiCodeRangeAnnotation20260930) TableName() string { + return "_tool_cursor_ai_code_range_annotations" +} + +func (*ensureCursorAiCodeRangeAnnotations) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &cursorAiCodeConversation20260930{}, + &cursorAiCodeRangeAnnotation20260930{}, + ) +} + +func (*ensureCursorAiCodeRangeAnnotations) Version() uint64 { return 20260930120000 } + +func (*ensureCursorAiCodeRangeAnnotations) Name() string { + return "cursor ensure ai code range annotation and conversation tables" +} diff --git a/backend/plugins/cursor/models/migrationscripts/20261002_widen_ai_code_conversation_id.go b/backend/plugins/cursor/models/migrationscripts/20261002_widen_ai_code_conversation_id.go new file mode 100644 index 00000000000..761399dd19b --- /dev/null +++ b/backend/plugins/cursor/models/migrationscripts/20261002_widen_ai_code_conversation_id.go @@ -0,0 +1,57 @@ +/* +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 migrationscripts + +import ( + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" +) + +var _ plugin.MigrationScript = (*widenCursorAiCodeConversationId)(nil) + +type widenCursorAiCodeConversationId struct{} + +// Cursor commit-detail groups can carry conversation ids longer than 64 +// characters (observed 91, including a tool-call id with an embedded newline). +// conversation_id is the primary key on conversations, so the alter keeps NOT NULL. +// On range annotations it is a nullable secondary index, so nullability stays open. +func (*widenCursorAiCodeConversationId) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + columns := []struct { + tableName string + columnType string + }{ + {"_tool_cursor_ai_code_conversations", "varchar(255) NOT NULL"}, + {"_tool_cursor_ai_code_range_annotations", "varchar(255)"}, + } + for _, column := range columns { + if err := db.ModifyColumnType(column.tableName, "conversation_id", column.columnType); err != nil { + return err + } + } + return nil +} + +func (*widenCursorAiCodeConversationId) Version() uint64 { + return 20261002131500 +} + +func (*widenCursorAiCodeConversationId) Name() string { + return "widen cursor ai code conversation_id columns to varchar(255)" +} diff --git a/backend/plugins/cursor/models/migrationscripts/20261002_widen_ai_code_conversation_id_test.go b/backend/plugins/cursor/models/migrationscripts/20261002_widen_ai_code_conversation_id_test.go new file mode 100644 index 00000000000..9eb854b2b68 --- /dev/null +++ b/backend/plugins/cursor/models/migrationscripts/20261002_widen_ai_code_conversation_id_test.go @@ -0,0 +1,86 @@ +/* +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 migrationscripts + +import ( + "testing" + + "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" +) + +type cursorConversationIdColumnCall struct { + tableName string + columnName string + columnType string +} + +type cursorConversationIdRecordingDal struct { + dal.Dal + calls []cursorConversationIdColumnCall +} + +func (d *cursorConversationIdRecordingDal) ModifyColumnType(tableName, columnName, columnType string) errors.Error { + d.calls = append(d.calls, cursorConversationIdColumnCall{tableName, columnName, columnType}) + return nil +} + +type cursorConversationIdBasicRes struct { + context.BasicRes + database dal.Dal +} + +func (r *cursorConversationIdBasicRes) GetDal() dal.Dal { + return r.database +} + +func TestWidenCursorAiCodeConversationId(t *testing.T) { + database := new(cursorConversationIdRecordingDal) + script := new(widenCursorAiCodeConversationId) + + if err := script.Up(&cursorConversationIdBasicRes{database: database}); err != nil { + t.Fatalf("Up() error = %v", err) + } + + want := []cursorConversationIdColumnCall{ + {"_tool_cursor_ai_code_conversations", "conversation_id", "varchar(255) NOT NULL"}, + {"_tool_cursor_ai_code_range_annotations", "conversation_id", "varchar(255)"}, + } + if len(database.calls) != len(want) { + t.Fatalf("ModifyColumnType calls = %#v, want %#v", database.calls, want) + } + for i := range want { + if database.calls[i] != want[i] { + t.Fatalf("ModifyColumnType calls = %#v, want %#v", database.calls, want) + } + } + if script.Version() != 20261002131500 { + t.Fatalf("Version() = %d, want 20261002131500", script.Version()) + } + if script.Name() != "widen cursor ai code conversation_id columns to varchar(255)" { + t.Fatalf("Name() = %q, want %q", script.Name(), "widen cursor ai code conversation_id columns to varchar(255)") + } + + for _, registeredScript := range All() { + if registeredScript.Version() == script.Version() { + return + } + } + t.Fatal("migration version 20261002131500 is not registered") +} diff --git a/backend/plugins/cursor/models/migrationscripts/register.go b/backend/plugins/cursor/models/migrationscripts/register.go index be0db9c2c29..67775adc885 100644 --- a/backend/plugins/cursor/models/migrationscripts/register.go +++ b/backend/plugins/cursor/models/migrationscripts/register.go @@ -26,5 +26,12 @@ func All() []plugin.MigrationScript { new(changeCursorRequestsCostsToFloat), new(addCursorDailyUsage), new(addCursorDailyUsageLineFields), + new(addCursorKeyTier), + new(fixCursorAiCodeSchema), + new(addCursorBugbotReviews), + new(addCursorConversationInsights), + new(addCursorAiCodeCommitDetails), + new(ensureCursorAiCodeRangeAnnotations), + new(widenCursorAiCodeConversationId), } } diff --git a/backend/plugins/cursor/models/models.go b/backend/plugins/cursor/models/models.go index 4edf5f7c60b..ea60dbe020f 100644 --- a/backend/plugins/cursor/models/models.go +++ b/backend/plugins/cursor/models/models.go @@ -29,5 +29,12 @@ func GetTablesInfo() []dal.Tabler { &CursorUserSpend{}, &CursorMember{}, &CursorDailyUsage{}, + &CursorAiCodeCommit{}, + &CursorAiCodeChange{}, + &CursorAiCodeConversation{}, + &CursorAiCodeRangeAnnotation{}, + &CursorBugBotReview{}, + &CursorBugBotFinding{}, + &CursorConversationInsight{}, } } diff --git a/backend/plugins/cursor/service/connection_permissions.go b/backend/plugins/cursor/service/connection_permissions.go new file mode 100644 index 00000000000..9f1fd295fbf --- /dev/null +++ b/backend/plugins/cursor/service/connection_permissions.go @@ -0,0 +1,166 @@ +/* +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 service + +import ( + stdctx "context" + "encoding/json" + "io" + "strings" + + corectx "github.com/apache/devlake/core/context" + "github.com/apache/devlake/core/errors" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/cursor/models" +) + +type connectionCapabilities struct { + KeyTier string + HasBugbotReviews bool + HasConversationInsights bool + HasAiCodeCommitDetails bool +} + +func snapshotConnectionCapabilities(connection *models.CursorConnection) connectionCapabilities { + if connection == nil { + return connectionCapabilities{} + } + return connectionCapabilities{ + KeyTier: connection.KeyTier, + HasBugbotReviews: connection.HasBugbotReviews, + HasConversationInsights: connection.HasConversationInsights, + HasAiCodeCommitDetails: connection.HasAiCodeCommitDetails, + } +} + +func probeOptionalEndpoints(apiClient *helper.ApiClient) AdminApiPermissions { + perms := AdminApiPermissions{ + Analytics: probeEndpoint(apiClient, "analytics/team/dau?startDate=7d&endDate=today"), + AiCodeTracking: probeEndpoint(apiClient, "analytics/ai-code/commits?page=1&pageSize=1"), + BugbotReviews: probeEndpoint(apiClient, "analytics/team/bugbot-reviews?page=1&pageSize=1"), + ConversationInsights: probeEndpoint(apiClient, "analytics/team/conversation-insights?startDate=7d&endDate=today&include=intents"), + } + if perms.AiCodeTracking { + perms.AiCodeCommitDetails = probeAiCodeCommitDetails(apiClient) + } + return perms +} + +// ApplyOptionalPermissions updates optional collection flags from live API probes. +// Key tier is upgraded to enterprise when analytics endpoints respond; it is never +// downgraded so a transient probe failure does not disable enterprise collectors. +func ApplyOptionalPermissions(connection *models.CursorConnection, perms AdminApiPermissions) { + if connection == nil { + return + } + if perms.Analytics || perms.AiCodeTracking { + connection.KeyTier = models.KeyTierEnterprise + } + connection.HasBugbotReviews = perms.BugbotReviews + connection.HasConversationInsights = perms.ConversationInsights + connection.HasAiCodeCommitDetails = perms.AiCodeCommitDetails +} + +func probeAiCodeCommitDetails(apiClient *helper.ApiClient) bool { + commitHash := firstAiCodeCommitHash(apiClient) + if commitHash == "" { + return false + } + return probeEndpoint(apiClient, "analytics/ai-code/commits/"+commitHash) +} + +func firstAiCodeCommitHash(apiClient *helper.ApiClient) string { + res, err := apiClient.Get("analytics/ai-code/commits?page=1&pageSize=1", nil, nil) + if err != nil || res == nil { + return "" + } + defer res.Body.Close() + if res.StatusCode != 200 { + return "" + } + body, readErr := io.ReadAll(res.Body) + if readErr != nil { + return "" + } + var response struct { + Items []struct { + CommitHash string `json:"commitHash"` + } `json:"items"` + Data []struct { + CommitHash string `json:"commitHash"` + } `json:"data"` + } + if jsonErr := json.Unmarshal(body, &response); jsonErr != nil { + return "" + } + if len(response.Items) > 0 { + return strings.TrimSpace(response.Items[0].CommitHash) + } + if len(response.Data) > 0 { + return strings.TrimSpace(response.Data[0].CommitHash) + } + return "" +} + +// RefreshOptionalPermissions re-probes optional Cursor Admin API endpoints and updates +// the in-memory connection. Call at pipeline start so collectors reflect current +// Cursor team settings without requiring a manual Test Connection. +func RefreshOptionalPermissions(ctx stdctx.Context, br corectx.BasicRes, connection *models.CursorConnection) (changed bool, err errors.Error) { + if connection == nil { + return false, nil + } + connection.Normalize() + if strings.TrimSpace(connection.Token) == "" || connection.KeyTier == models.KeyTierPersonal { + return false, nil + } + + apiClient, err := helper.NewApiClientFromConnection(ctx, br, connection) + if err != nil { + return false, err + } + apiClient.SetHeaders(map[string]string{ + "Accept": "application/json", + "Content-Type": "application/json", + }) + + before := snapshotConnectionCapabilities(connection) + perms := probeOptionalEndpoints(apiClient) + ApplyOptionalPermissions(connection, perms) + changed = before != snapshotConnectionCapabilities(connection) + + br.GetLogger().Info( + "cursor optional permissions refreshed: keyTier=%s bugbotReviews=%v conversationInsights=%v aiCodeCommitDetails=%v", + connection.KeyTier, + connection.HasBugbotReviews, + connection.HasConversationInsights, + connection.HasAiCodeCommitDetails, + ) + return changed, nil +} + +// RefreshAndPersistOptionalPermissions probes optional endpoints and saves changes to the connection row. +func RefreshAndPersistOptionalPermissions(ctx stdctx.Context, br corectx.BasicRes, connection *models.CursorConnection) errors.Error { + changed, err := RefreshOptionalPermissions(ctx, br, connection) + if err != nil { + return err + } + if !changed || connection.ID == 0 { + return nil + } + return br.GetDal().CreateOrUpdate(connection) +} diff --git a/backend/plugins/cursor/service/connection_permissions_test.go b/backend/plugins/cursor/service/connection_permissions_test.go new file mode 100644 index 00000000000..122d2d5ab1f --- /dev/null +++ b/backend/plugins/cursor/service/connection_permissions_test.go @@ -0,0 +1,74 @@ +/* +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 service + +import ( + "testing" + + "github.com/apache/devlake/plugins/cursor/models" + "github.com/stretchr/testify/require" +) + +func TestApplyOptionalPermissions_UpgradesEnterpriseAndSetsFlags(t *testing.T) { + conn := &models.CursorConnection{ + CursorConn: models.CursorConn{KeyTier: models.KeyTierTeam}, + } + ApplyOptionalPermissions(conn, AdminApiPermissions{ + Analytics: true, + BugbotReviews: true, + ConversationInsights: true, + AiCodeCommitDetails: true, + }) + require.Equal(t, models.KeyTierEnterprise, conn.KeyTier) + require.True(t, conn.HasBugbotReviews) + require.True(t, conn.HasConversationInsights) + require.True(t, conn.HasAiCodeCommitDetails) +} + +func TestApplyOptionalPermissions_DoesNotDowngradeEnterprise(t *testing.T) { + conn := &models.CursorConnection{ + CursorConn: models.CursorConn{ + KeyTier: models.KeyTierEnterprise, + HasBugbotReviews: true, + HasConversationInsights: true, + }, + } + ApplyOptionalPermissions(conn, AdminApiPermissions{ + Analytics: false, + AiCodeTracking: false, + BugbotReviews: false, + ConversationInsights: false, + }) + require.Equal(t, models.KeyTierEnterprise, conn.KeyTier) + require.False(t, conn.HasBugbotReviews) + require.False(t, conn.HasConversationInsights) + require.False(t, conn.HasAiCodeCommitDetails) +} + +func TestSnapshotConnectionCapabilitiesDetectsChanges(t *testing.T) { + conn := &models.CursorConnection{ + CursorConn: models.CursorConn{ + KeyTier: models.KeyTierTeam, + HasConversationInsights: false, + }, + } + before := snapshotConnectionCapabilities(conn) + ApplyOptionalPermissions(conn, AdminApiPermissions{ConversationInsights: true}) + after := snapshotConnectionCapabilities(conn) + require.NotEqual(t, before, after) +} diff --git a/backend/plugins/cursor/service/connection_test.go b/backend/plugins/cursor/service/connection_test.go index c520351f1e5..1506d7a5711 100644 --- a/backend/plugins/cursor/service/connection_test.go +++ b/backend/plugins/cursor/service/connection_test.go @@ -23,6 +23,7 @@ import ( "strings" "testing" + "github.com/apache/devlake/plugins/cursor/models" "github.com/stretchr/testify/require" ) @@ -57,3 +58,107 @@ func TestBuildAdminApiError_Forbidden(t *testing.T) { require.Contains(t, err.Error(), "forbidden") require.Contains(t, err.Error(), "filtered-usage-events") } + +func TestKeyTierConstants(t *testing.T) { + require.Equal(t, "personal", models.KeyTierPersonal) + require.Equal(t, "team", models.KeyTierTeam) + require.Equal(t, "enterprise", models.KeyTierEnterprise) +} + +func TestTestConnectionResult_KeyTierField(t *testing.T) { + result := &TestConnectionResult{ + Success: true, + KeyTier: models.KeyTierEnterprise, + Permissions: AdminApiPermissions{ + Members: true, + Spend: true, + UsageEvents: true, + Analytics: true, + AiCodeTracking: true, + }, + } + require.Equal(t, models.KeyTierEnterprise, result.KeyTier) + require.True(t, result.Permissions.Analytics) + require.True(t, result.Permissions.AiCodeTracking) +} + +func TestTestConnectionResult_TeamKeyTier(t *testing.T) { + result := &TestConnectionResult{ + Success: true, + KeyTier: models.KeyTierTeam, + Permissions: AdminApiPermissions{ + Members: true, + Spend: true, + UsageEvents: true, + Analytics: false, + AiCodeTracking: false, + }, + } + require.Equal(t, models.KeyTierTeam, result.KeyTier) + require.False(t, result.Permissions.Analytics) + require.False(t, result.Permissions.AiCodeTracking) +} + +func TestAdminApiPermissions_EnterpriseFields(t *testing.T) { + perms := AdminApiPermissions{ + Members: true, + Spend: true, + UsageEvents: true, + Analytics: true, + AiCodeTracking: false, + } + require.True(t, perms.Analytics) + require.False(t, perms.AiCodeTracking) +} + +func TestApplyTestResultToConnection(t *testing.T) { + conn := &models.CursorConnection{} + ApplyTestResultToConnection(conn, &TestConnectionResult{ + KeyTier: models.KeyTierEnterprise, + Permissions: AdminApiPermissions{ + BugbotReviews: true, + ConversationInsights: true, + }, + }) + require.Equal(t, models.KeyTierEnterprise, conn.KeyTier) + require.True(t, conn.HasBugbotReviews) + require.True(t, conn.HasConversationInsights) + + ApplyTestResultToConnection(conn, nil) + require.Equal(t, models.KeyTierEnterprise, conn.KeyTier) + require.True(t, conn.HasBugbotReviews) + require.True(t, conn.HasConversationInsights) + + ApplyTestResultToConnection(conn, &TestConnectionResult{ + Permissions: AdminApiPermissions{BugbotReviews: false, ConversationInsights: false}, + }) + require.Equal(t, models.KeyTierEnterprise, conn.KeyTier) + require.False(t, conn.HasBugbotReviews) + require.False(t, conn.HasConversationInsights) +} + +func TestFailedConnectionResult_OmitsKeyTierWhenUnauthorized(t *testing.T) { + permissions := AdminApiPermissions{} + result := &TestConnectionResult{ + Success: false, + Message: "Cannot list team members (teams/members): unauthorized.", + Permissions: permissions, + } + if permissions.Members || permissions.Spend || permissions.UsageEvents { + result.KeyTier = models.KeyTierTeam + } + require.Empty(t, result.KeyTier) +} + +func TestFailedConnectionResult_SetsKeyTierOnPartialAccess(t *testing.T) { + permissions := AdminApiPermissions{Members: true} + result := &TestConnectionResult{ + Success: false, + Message: "missing spend permission", + Permissions: permissions, + } + if permissions.Members || permissions.Spend || permissions.UsageEvents { + result.KeyTier = models.KeyTierTeam + } + require.Equal(t, models.KeyTierTeam, result.KeyTier) +} diff --git a/backend/plugins/cursor/service/connection_test_helper.go b/backend/plugins/cursor/service/connection_test_helper.go index d9faec75dfb..38752c2bb3d 100644 --- a/backend/plugins/cursor/service/connection_test_helper.go +++ b/backend/plugins/cursor/service/connection_test_helper.go @@ -38,20 +38,25 @@ const ( usageEventsEndpoint = "teams/filtered-usage-events" ) -// AdminApiPermissions reports which Cursor Admin API endpoints the key can access. +// AdminApiPermissions reports which Cursor API endpoints the key can access. type AdminApiPermissions struct { - Members bool `json:"members"` - Spend bool `json:"spend"` - UsageEvents bool `json:"usageEvents"` + Members bool `json:"members"` + Spend bool `json:"spend"` + UsageEvents bool `json:"usageEvents"` + Analytics bool `json:"analytics"` + AiCodeTracking bool `json:"aiCodeTracking"` + BugbotReviews bool `json:"bugbotReviews"` + ConversationInsights bool `json:"conversationInsights"` + AiCodeCommitDetails bool `json:"aiCodeCommitDetails"` } // TestConnectionResult represents the payload returned by the connection test endpoints. type TestConnectionResult struct { - Success bool `json:"success"` - Message string `json:"message"` - MemberCount int `json:"memberCount,omitempty"` - Permissions AdminApiPermissions `json:"permissions,omitempty"` - HasEnterpriseAnalytics bool `json:"hasEnterpriseAnalytics,omitempty"` + Success bool `json:"success"` + Message string `json:"message"` + MemberCount int `json:"memberCount,omitempty"` + Permissions AdminApiPermissions `json:"permissions,omitempty"` + KeyTier string `json:"keyTier,omitempty"` } // TestConnection exercises the Cursor Admin API to validate credentials and permissions. @@ -78,6 +83,7 @@ func TestConnection(ctx stdctx.Context, br corectx.BasicRes, connection *models. return &TestConnectionResult{ Success: false, Message: userKeyErr.Error(), + KeyTier: models.KeyTierPersonal, }, nil } @@ -105,25 +111,76 @@ func TestConnection(ctx stdctx.Context, br corectx.BasicRes, connection *models. } if len(failures) > 0 { - return &TestConnectionResult{ + result := &TestConnectionResult{ Success: false, Message: buildPermissionFailureMessage(failures), MemberCount: memberCount, Permissions: permissions, - }, nil + } + if permissions.Members || permissions.Spend || permissions.UsageEvents { + result.KeyTier = models.KeyTierTeam + } + return result, nil + } + + optional := probeOptionalEndpoints(apiClient) + permissions.Analytics = optional.Analytics + permissions.AiCodeTracking = optional.AiCodeTracking + permissions.BugbotReviews = optional.BugbotReviews + permissions.ConversationInsights = optional.ConversationInsights + permissions.AiCodeCommitDetails = optional.AiCodeCommitDetails + + keyTier := models.KeyTierTeam + if permissions.Analytics || permissions.AiCodeTracking { + keyTier = models.KeyTierEnterprise } - hasEnterpriseAnalytics := probeEnterpriseAnalytics(apiClient) + msg := "Team Admin API key validated. Members, spend, and usage events are accessible." + if keyTier == models.KeyTierEnterprise { + msg = "Enterprise Admin API key validated. Team data, analytics, and AI code tracking are accessible." + } + if permissions.BugbotReviews { + msg += " BugBot review analytics are accessible." + } + if permissions.ConversationInsights { + msg += " Conversation Insights are accessible." + } + if permissions.AiCodeCommitDetails { + msg += " AI code commit details are accessible." + } return &TestConnectionResult{ - Success: true, - Message: "Team Admin API key validated. Members, spend, and usage events are accessible.", - MemberCount: memberCount, - Permissions: permissions, - HasEnterpriseAnalytics: hasEnterpriseAnalytics, + Success: true, + Message: msg, + MemberCount: memberCount, + Permissions: permissions, + KeyTier: keyTier, }, nil } +// ApplyTestResultToConnection copies detected capabilities from a test result onto the connection. +func ApplyTestResultToConnection(connection *models.CursorConnection, result *TestConnectionResult) { + if connection == nil || result == nil { + return + } + if result.KeyTier != "" { + connection.KeyTier = result.KeyTier + } + connection.HasBugbotReviews = result.Permissions.BugbotReviews + connection.HasConversationInsights = result.Permissions.ConversationInsights + connection.HasAiCodeCommitDetails = result.Permissions.AiCodeCommitDetails +} + +// PopulateKeyTier probes the Cursor API and sets connection.KeyTier from the result. +func PopulateKeyTier(ctx stdctx.Context, br corectx.BasicRes, connection *models.CursorConnection) errors.Error { + result, err := TestConnection(ctx, br, connection) + if err != nil { + return err + } + ApplyTestResultToConnection(connection, result) + return nil +} + func probeMembers(apiClient *helper.ApiClient) (int, errors.Error) { res, err := apiClient.Get(membersEndpoint, nil, nil) if err != nil { @@ -234,8 +291,8 @@ func detectUserApiKey(apiClient *helper.ApiClient) errors.Error { return nil } -func probeEnterpriseAnalytics(apiClient *helper.ApiClient) bool { - res, err := apiClient.Get("analytics/team/dau?startDate=7d&endDate=today", nil, nil) +func probeEndpoint(apiClient *helper.ApiClient, path string) bool { + res, err := apiClient.Get(path, nil, nil) if err != nil || res == nil { return false } diff --git a/backend/plugins/cursor/tasks/ai_code_changes_collector.go b/backend/plugins/cursor/tasks/ai_code_changes_collector.go new file mode 100644 index 00000000000..7e7d08a2e07 --- /dev/null +++ b/backend/plugins/cursor/tasks/ai_code_changes_collector.go @@ -0,0 +1,92 @@ +/* +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 ( + "fmt" + "net/http" + "net/url" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/cursor/models" +) + +const rawAiCodeChangesTable = "cursor_ai_code_changes" + +// CollectAiCodeChanges collects granular accepted AI changes from +// GET /analytics/ai-code/changes (Enterprise only). +func CollectAiCodeChanges(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*CursorTaskData) + if !ok { + return errors.Default.New("task data is not CursorTaskData") + } + if data.Connection.KeyTier != models.KeyTierEnterprise { + taskCtx.GetLogger().Info("Skipping AI code change collection: key tier is %q, not enterprise", data.Connection.KeyTier) + return nil + } + + apiClient, err := CreateApiClient(taskCtx.TaskContext(), data.Connection) + if err != nil { + return err + } + + rawArgs := helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawAiCodeChangesTable, + Options: rawParamsFromTaskData(data), + } + + collector, err := helper.NewStatefulApiCollector(rawArgs) + if err != nil { + return err + } + + dateRangeIter := newAnalyticsDateRangeIterator(collector.GetSince(), collector.IsIncremental()) + + err = collector.InitCollector(helper.ApiCollectorArgs{ + ApiClient: apiClient, + Input: dateRangeIter, + UrlTemplate: "analytics/ai-code/changes", + Query: func(reqData *helper.RequestData) (url.Values, errors.Error) { + input := reqData.Input.(analyticsDateRangeInput) + q := url.Values{} + q.Set("startDate", input.StartDate) + q.Set("endDate", input.EndDate) + q.Set("page", fmt.Sprintf("%d", reqData.Pager.Page)) + q.Set("pageSize", fmt.Sprintf("%d", reqData.Pager.Size)) + return q, nil + }, + PageSize: cursorApiPageSize, + GetTotalPages: func(res *http.Response, args *helper.ApiCollectorArgs) (int, errors.Error) { + body, readErr := readResponseBody(res) + if readErr != nil { + return 0, readErr + } + return parseAiCodeTrackingTotalPages(body, args.PageSize) + }, + ResponseParser: parseAiCodeTrackingItemsResponse, + }) + if err != nil { + return err + } + + logUsageCollectionWindow(taskCtx.GetLogger(), "analytics/ai-code/changes", collector.GetSince(), collector.IsIncremental()) + return collector.Execute() +} diff --git a/backend/plugins/cursor/tasks/ai_code_changes_extractor.go b/backend/plugins/cursor/tasks/ai_code_changes_extractor.go new file mode 100644 index 00000000000..309cea32d17 --- /dev/null +++ b/backend/plugins/cursor/tasks/ai_code_changes_extractor.go @@ -0,0 +1,91 @@ +/* +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" + "strings" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/cursor/models" +) + +type aiCodeChangeRecord struct { + ChangeId string `json:"changeId"` + UserId string `json:"userId"` + UserEmail string `json:"userEmail"` + Source string `json:"source"` + Model string `json:"model"` + TotalLinesAdded int `json:"totalLinesAdded"` + TotalLinesDeleted int `json:"totalLinesDeleted"` + CreatedAt string `json:"createdAt"` + Metadata json.RawMessage `json:"metadata"` +} + +// ExtractAiCodeChanges parses raw AI code change records into tool-layer tables. +func ExtractAiCodeChanges(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*CursorTaskData) + if !ok { + return errors.Default.New("task data is not CursorTaskData") + } + if data.Connection.KeyTier != models.KeyTierEnterprise { + taskCtx.GetLogger().Info("Skipping AI code change extraction: key tier is %q, not enterprise", data.Connection.KeyTier) + return nil + } + + extractor, err := newCursorStatefulExtractor(&cursorStatefulExtractorArgs[aiCodeChangeRecord]{ + SubtaskCommonArgs: cursorSubtaskCommonArgs(taskCtx, data, rawAiCodeChangesTable), + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + ToolTable: models.CursorAiCodeChange{}.TableName(), + Extract: func(record *aiCodeChangeRecord, row *helper.RawData) ([]any, errors.Error) { + changeId := strings.TrimSpace(record.ChangeId) + if changeId == "" { + return nil, nil + } + + var metadataStr string + if len(record.Metadata) > 0 && string(record.Metadata) != "null" { + metadataStr = string(record.Metadata) + } + + change := &models.CursorAiCodeChange{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + ChangeId: changeId, + UserId: strings.TrimSpace(record.UserId), + UserEmail: strings.TrimSpace(record.UserEmail), + Source: strings.TrimSpace(record.Source), + Model: strings.TrimSpace(record.Model), + TotalLinesAdded: record.TotalLinesAdded, + TotalLinesDeleted: record.TotalLinesDeleted, + Metadata: metadataStr, + } + if ts := parseOptionalISOTime(record.CreatedAt); ts != nil { + change.IngestedAt = ts + } + return []any{change}, nil + }, + }) + if err != nil { + return err + } + return extractor.Execute() +} diff --git a/backend/plugins/cursor/tasks/ai_code_commit_details_collector.go b/backend/plugins/cursor/tasks/ai_code_commit_details_collector.go new file mode 100644 index 00000000000..ee4be088a9f --- /dev/null +++ b/backend/plugins/cursor/tasks/ai_code_commit_details_collector.go @@ -0,0 +1,166 @@ +/* +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" + "net/http" + "net/url" + "strings" + + "github.com/apache/devlake/core/dal" + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/cursor/models" +) + +const rawAiCodeCommitDetailsTable = "cursor_ai_code_commit_details" + +type aiCodeCommitDetailInput struct { + CommitHash string + BranchName string +} + +// CollectAiCodeCommitDetails collects file-level blame and conversation metadata from +// GET /analytics/ai-code/commits/:commitHash (Enterprise alpha; skipped when inaccessible). +func CollectAiCodeCommitDetails(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*CursorTaskData) + if !ok { + return errors.Default.New("task data is not CursorTaskData") + } + if data.Connection.KeyTier != models.KeyTierEnterprise { + taskCtx.GetLogger().Info("Skipping AI code commit detail collection: key tier is %q, not enterprise", data.Connection.KeyTier) + return nil + } + if !data.Connection.HasAiCodeCommitDetails { + taskCtx.GetLogger().Info("Skipping AI code commit detail collection: connection lacks commit-details access") + return nil + } + + apiClient, err := CreateApiClient(taskCtx.TaskContext(), data.Connection) + if err != nil { + return err + } + + commitIter, pendingCommits, err := newAiCodeCommitDetailIterator(taskCtx, data) + if err != nil { + return err + } + if pendingCommits == 0 { + taskCtx.GetLogger().Info("No AI code commits pending commit-detail collection") + return nil + } + + rawArgs := helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawAiCodeCommitDetailsTable, + Options: rawParamsFromTaskData(data), + } + + collector, err := helper.NewApiCollector(helper.ApiCollectorArgs{ + RawDataSubTaskArgs: rawArgs, + ApiClient: apiClient, + Input: commitIter, + PageSize: 1, + UrlTemplate: "analytics/ai-code/commits/{{ .Input.CommitHash }}", + Query: func(reqData *helper.RequestData) (url.Values, errors.Error) { + input, ok := reqData.Input.(aiCodeCommitDetailInput) + if !ok { + return nil, errors.Default.New("invalid input type for commit-detail collector") + } + q := url.Values{} + if branch := strings.TrimSpace(input.BranchName); branch != "" { + q.Set("branch", branch) + } + return q, nil + }, + GetTotalPages: func(_ *http.Response, _ *helper.ApiCollectorArgs) (int, errors.Error) { + return 1, nil + }, + ResponseParser: parseCommitDetailsWholeBodyResponse, + AfterResponse: ignoreHTTPStatus404, + }) + if err != nil { + return err + } + + taskCtx.GetLogger().Info("Collecting AI code commit details for %d commit(s)", pendingCommits) + return collector.Execute() +} + +func newAiCodeCommitDetailIterator(taskCtx plugin.SubTaskContext, data *CursorTaskData) (*helper.QueueIterator, int, errors.Error) { + db := taskCtx.GetDal() + commitTable := models.CursorAiCodeCommit{}.TableName() + detailTable := models.CursorAiCodeRangeAnnotation{}.TableName() + + clauses := []dal.Clause{ + dal.Select("c.commit_hash", "c.branch_name"), + dal.From(commitTable + " c"), + dal.Where("c.connection_id = ? AND c.scope_id = ?", data.Options.ConnectionId, data.Options.ScopeId), + dal.Orderby("c.commit_hash ASC"), + } + if db.HasTable(detailTable) { + clauses = append(clauses, dal.Where(`NOT EXISTS ( + SELECT 1 FROM `+detailTable+` r + WHERE r.connection_id = c.connection_id + AND r.scope_id = c.scope_id + AND r.commit_hash = c.commit_hash + )`)) + } + + cursor, err := db.Cursor(clauses...) + if err != nil { + return nil, 0, err + } + defer cursor.Close() + + iter := helper.NewQueueIterator() + pendingCommits := 0 + for cursor.Next() { + var row struct { + CommitHash string + BranchName string + } + if fetchErr := db.Fetch(cursor, &row); fetchErr != nil { + return nil, 0, errors.Default.Wrap(fetchErr, "failed to read commit for detail collection") + } + hash := strings.TrimSpace(row.CommitHash) + if hash == "" { + continue + } + iter.Push(aiCodeCommitDetailInput{ + CommitHash: hash, + BranchName: strings.TrimSpace(row.BranchName), + }) + pendingCommits++ + } + + return iter, pendingCommits, nil +} + +func parseCommitDetailsWholeBodyResponse(res *http.Response) ([]json.RawMessage, errors.Error) { + body, err := readResponseBody(res) + if err != nil { + return nil, err + } + if len(body) == 0 { + return nil, nil + } + return []json.RawMessage{body}, nil +} diff --git a/backend/plugins/cursor/tasks/ai_code_commit_details_extractor.go b/backend/plugins/cursor/tasks/ai_code_commit_details_extractor.go new file mode 100644 index 00000000000..68669b64ab9 --- /dev/null +++ b/backend/plugins/cursor/tasks/ai_code_commit_details_extractor.go @@ -0,0 +1,197 @@ +/* +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 ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/cursor/models" +) + +type aiCodeCommitDetailResponse struct { + Commits []aiCodeCommitDetailCommit `json:"commits"` + Conversations []aiCodeCommitDetailConversation `json:"conversations"` +} + +type aiCodeCommitDetailCommit struct { + CommitHash string `json:"commitHash"` + CommitSource string `json:"commitSource"` + RangeAnnotations []aiCodeCommitDetailFileBlame `json:"rangeAnnotations"` +} + +type aiCodeCommitDetailFileBlame struct { + FilePath string `json:"filePath"` + Groups []aiCodeCommitDetailGroup `json:"groups"` +} + +type aiCodeCommitDetailGroup struct { + ConversationId *string `json:"conversationId"` + Model *string `json:"model"` + OperationType string `json:"operationType"` + Ranges []aiCodeCommitDetailRange `json:"ranges"` +} + +type aiCodeCommitDetailRange struct { + Start int `json:"start"` + End int `json:"end"` +} + +type aiCodeCommitDetailConversation struct { + Id string `json:"id"` + Title *string `json:"title"` + Tldr *string `json:"tldr"` + Overview *string `json:"overview"` + SummaryBullets []string `json:"summaryBullets"` +} + +// ExtractAiCodeCommitDetails parses commit-detail payloads into conversation and range annotation tables. +func ExtractAiCodeCommitDetails(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*CursorTaskData) + if !ok { + return errors.Default.New("task data is not CursorTaskData") + } + if data.Connection.KeyTier != models.KeyTierEnterprise { + taskCtx.GetLogger().Info("Skipping AI code commit detail extraction: key tier is %q, not enterprise", data.Connection.KeyTier) + return nil + } + if !data.Connection.HasAiCodeCommitDetails { + taskCtx.GetLogger().Info("Skipping AI code commit detail extraction: connection lacks commit-details access") + return nil + } + + extractor, err := newCursorStatefulExtractor(&cursorStatefulExtractorArgs[aiCodeCommitDetailResponse]{ + SubtaskCommonArgs: cursorSubtaskCommonArgs(taskCtx, data, rawAiCodeCommitDetailsTable), + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + ToolTable: models.CursorAiCodeRangeAnnotation{}.TableName(), + Extract: func(record *aiCodeCommitDetailResponse, _ *helper.RawData) ([]any, errors.Error) { + return flattenAiCodeCommitDetailResponse(data.Options.ConnectionId, data.Options.ScopeId, record) + }, + }) + if err != nil { + return err + } + return extractor.Execute() +} + +func flattenAiCodeCommitDetailResponse(connectionId uint64, scopeId string, record *aiCodeCommitDetailResponse) ([]any, errors.Error) { + if record == nil { + return nil, nil + } + + var rows []any + for _, conversation := range record.Conversations { + conversationId := strings.TrimSpace(conversation.Id) + if conversationId == "" { + continue + } + summaryBullets := "" + if len(conversation.SummaryBullets) > 0 { + encoded, jsonErr := json.Marshal(conversation.SummaryBullets) + if jsonErr != nil { + return nil, errors.Default.Wrap(errors.Convert(jsonErr), "failed to encode conversation summary bullets") + } + summaryBullets = string(encoded) + } + rows = append(rows, &models.CursorAiCodeConversation{ + ConnectionId: connectionId, + ScopeId: scopeId, + ConversationId: conversationId, + Title: stringPtrValue(conversation.Title), + Tldr: stringPtrValue(conversation.Tldr), + Overview: stringPtrValue(conversation.Overview), + SummaryBullets: summaryBullets, + }) + } + + for _, commit := range record.Commits { + commitHash := strings.TrimSpace(commit.CommitHash) + if commitHash == "" { + continue + } + for _, fileBlame := range commit.RangeAnnotations { + filePath := strings.TrimSpace(fileBlame.FilePath) + if filePath == "" { + continue + } + for _, group := range fileBlame.Groups { + conversationId := "" + if group.ConversationId != nil { + conversationId = strings.TrimSpace(*group.ConversationId) + } + model := "" + if group.Model != nil { + model = strings.TrimSpace(*group.Model) + } + for _, lineRange := range group.Ranges { + if lineRange.End < lineRange.Start { + continue + } + lineCount := lineRange.End - lineRange.Start + 1 + if lineRange.End == 0 && lineRange.Start == 0 { + lineCount = 0 + } + operationType := strings.TrimSpace(group.OperationType) + rows = append(rows, &models.CursorAiCodeRangeAnnotation{ + ConnectionId: connectionId, + ScopeId: scopeId, + AnnotationId: computeAiCodeRangeAnnotationId( + commitHash, filePath, conversationId, model, operationType, lineRange.Start, lineRange.End, + ), + CommitHash: commitHash, + FilePath: filePath, + ConversationId: conversationId, + RangeStart: lineRange.Start, + RangeEnd: lineRange.End, + Model: model, + OperationType: operationType, + LineCount: lineCount, + }) + } + } + } + } + + return rows, nil +} + +func stringPtrValue(value *string) string { + if value == nil { + return "" + } + return strings.TrimSpace(*value) +} + +func computeAiCodeRangeAnnotationId( + commitHash, filePath, conversationId, model, operationType string, + rangeStart, rangeEnd int, +) string { + payload := fmt.Sprintf( + "%s|%s|%s|%s|%s|%d|%d", + commitHash, filePath, conversationId, model, operationType, rangeStart, rangeEnd, + ) + sum := sha256.Sum256([]byte(payload)) + return hex.EncodeToString(sum[:]) +} diff --git a/backend/plugins/cursor/tasks/ai_code_commit_details_test.go b/backend/plugins/cursor/tasks/ai_code_commit_details_test.go new file mode 100644 index 00000000000..066391f068d --- /dev/null +++ b/backend/plugins/cursor/tasks/ai_code_commit_details_test.go @@ -0,0 +1,109 @@ +/* +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" + "testing" + + "github.com/apache/devlake/plugins/cursor/models" + "github.com/stretchr/testify/require" +) + +const sampleCommitDetailResponse = `{ + "commits": [ + { + "commitHash": "abc123def456", + "commitSource": "ide", + "rangeAnnotations": [ + { + "filePath": "backend/plugins/cursor/tasks/ai_code_commits_collector.go", + "groups": [ + { + "conversationId": "846952e0-0221-473b-bb1d-e5d016f071b1", + "model": "composer-2.5-fast", + "operationType": "insert", + "ranges": [ + {"start": 10, "end": 25}, + {"start": 42, "end": 58} + ] + } + ] + } + ] + } + ], + "conversations": [ + { + "id": "846952e0-0221-473b-bb1d-e5d016f071b1", + "title": "Refactor analytics module", + "tldr": "Extracted report generation into separate functions", + "overview": "Refactored the analytics module to improve maintainability.", + "summaryBullets": [ + "Created dedicated report generator class", + "Added unit tests for new functions" + ] + } + ] +}` + +func TestFlattenAiCodeCommitDetailResponse(t *testing.T) { + var payload aiCodeCommitDetailResponse + require.NoError(t, json.Unmarshal([]byte(sampleCommitDetailResponse), &payload)) + + rows, err := flattenAiCodeCommitDetailResponse(1, "team", &payload) + require.NoError(t, err) + require.Len(t, rows, 3) + + var conversations int + var annotations int + for _, row := range rows { + switch typed := row.(type) { + case *models.CursorAiCodeConversation: + conversations++ + require.Equal(t, "846952e0-0221-473b-bb1d-e5d016f071b1", typed.ConversationId) + require.Equal(t, "Refactor analytics module", typed.Title) + require.Contains(t, typed.SummaryBullets, "Created dedicated report generator class") + case *models.CursorAiCodeRangeAnnotation: + annotations++ + require.NotEmpty(t, typed.AnnotationId) + require.Equal(t, "abc123def456", typed.CommitHash) + require.Equal(t, "composer-2.5-fast", typed.Model) + require.Equal(t, "insert", typed.OperationType) + require.Greater(t, typed.LineCount, 0) + default: + t.Fatalf("unexpected row type %T", row) + } + } + require.Equal(t, 1, conversations) + require.Equal(t, 2, annotations) +} + +func TestParseCommitDetailsWholeBodyResponse(t *testing.T) { + body := []byte(sampleCommitDetailResponse) + items, err := parseCommitDetailsWholeBodyResponseBody(body) + require.NoError(t, err) + require.Len(t, items, 1) +} + +func parseCommitDetailsWholeBodyResponseBody(body []byte) ([]json.RawMessage, error) { + if len(body) == 0 { + return nil, nil + } + return []json.RawMessage{body}, nil +} diff --git a/backend/plugins/cursor/tasks/ai_code_commits_collector.go b/backend/plugins/cursor/tasks/ai_code_commits_collector.go new file mode 100644 index 00000000000..df5071e164c --- /dev/null +++ b/backend/plugins/cursor/tasks/ai_code_commits_collector.go @@ -0,0 +1,134 @@ +/* +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 ( + "fmt" + "net/http" + "net/url" + "time" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/cursor/models" +) + +const rawAiCodeCommitsTable = "cursor_ai_code_commits" + +// CollectAiCodeCommits collects per-commit AI line attribution from +// GET /analytics/ai-code/commits (Enterprise only). +func CollectAiCodeCommits(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*CursorTaskData) + if !ok { + return errors.Default.New("task data is not CursorTaskData") + } + if data.Connection.KeyTier != models.KeyTierEnterprise { + taskCtx.GetLogger().Info("Skipping AI code commit collection: key tier is %q, not enterprise", data.Connection.KeyTier) + return nil + } + + apiClient, err := CreateApiClient(taskCtx.TaskContext(), data.Connection) + if err != nil { + return err + } + + rawArgs := helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawAiCodeCommitsTable, + Options: rawParamsFromTaskData(data), + } + + collector, err := helper.NewStatefulApiCollector(rawArgs) + if err != nil { + return err + } + + dateRangeIter := newAnalyticsDateRangeIterator(collector.GetSince(), collector.IsIncremental()) + + err = collector.InitCollector(helper.ApiCollectorArgs{ + ApiClient: apiClient, + Input: dateRangeIter, + UrlTemplate: "analytics/ai-code/commits", + Query: func(reqData *helper.RequestData) (url.Values, errors.Error) { + input := reqData.Input.(analyticsDateRangeInput) + q := url.Values{} + q.Set("startDate", input.StartDate) + q.Set("endDate", input.EndDate) + q.Set("page", fmt.Sprintf("%d", reqData.Pager.Page)) + q.Set("pageSize", fmt.Sprintf("%d", reqData.Pager.Size)) + return q, nil + }, + PageSize: cursorApiPageSize, + GetTotalPages: func(res *http.Response, args *helper.ApiCollectorArgs) (int, errors.Error) { + body, readErr := readResponseBody(res) + if readErr != nil { + return 0, readErr + } + return parseAiCodeTrackingTotalPages(body, args.PageSize) + }, + ResponseParser: parseAiCodeTrackingItemsResponse, + }) + if err != nil { + return err + } + + logUsageCollectionWindow(taskCtx.GetLogger(), "analytics/ai-code/commits", collector.GetSince(), collector.IsIncremental()) + return collector.Execute() +} + +type analyticsDateRangeInput struct { + StartDate string + EndDate string +} + +func newAnalyticsDateRangeIterator(since *time.Time, isIncremental bool) *helper.QueueIterator { + startMs, endMs := computeUsageTimeRangeMs(since, time.Now().UTC(), isIncremental) + iter := helper.NewQueueIterator() + for _, chunk := range splitAnalyticsDateRange(startMs, endMs, cursorDailyUsageMaxDays) { + iter.Push(chunk) + } + return iter +} + +// splitAnalyticsDateRange splits [startMs, endMs] into ISO-date chunks. +func splitAnalyticsDateRange(startMs, endMs int64, maxDays int) []analyticsDateRangeInput { + if startMs >= endMs || maxDays <= 0 { + return nil + } + maxDuration := time.Duration(maxDays) * 24 * time.Hour + chunkStart := time.UnixMilli(startMs).UTC() + end := time.UnixMilli(endMs).UTC() + + var chunks []analyticsDateRangeInput + for chunkStart.Before(end) { + chunkEnd := chunkStart.Add(maxDuration) + if chunkEnd.After(end) { + chunkEnd = end + } + chunks = append(chunks, analyticsDateRangeInput{ + StartDate: chunkStart.Format("2006-01-02"), + EndDate: chunkEnd.Format("2006-01-02"), + }) + if !chunkEnd.Before(end) { + break + } + chunkStart = chunkEnd.Add(time.Millisecond) + } + return chunks +} diff --git a/backend/plugins/cursor/tasks/ai_code_commits_extractor.go b/backend/plugins/cursor/tasks/ai_code_commits_extractor.go new file mode 100644 index 00000000000..b5788e7458a --- /dev/null +++ b/backend/plugins/cursor/tasks/ai_code_commits_extractor.go @@ -0,0 +1,128 @@ +/* +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 ( + "strings" + "time" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/cursor/models" +) + +type aiCodeCommitRecord struct { + CommitHash string `json:"commitHash"` + UserId string `json:"userId"` + UserEmail string `json:"userEmail"` + RepoName string `json:"repoName"` + BranchName string `json:"branchName"` + IsPrimaryBranch *bool `json:"isPrimaryBranch"` + CommitSource string `json:"commitSource"` + TotalLinesAdded int `json:"totalLinesAdded"` + TotalLinesDeleted int `json:"totalLinesDeleted"` + TabLinesAdded int `json:"tabLinesAdded"` + TabLinesDeleted int `json:"tabLinesDeleted"` + ComposerLinesAdded int `json:"composerLinesAdded"` + ComposerLinesDeleted int `json:"composerLinesDeleted"` + NonAiLinesAdded *int `json:"nonAiLinesAdded"` + NonAiLinesDeleted *int `json:"nonAiLinesDeleted"` + CommitTs string `json:"commitTs"` + CreatedAt string `json:"createdAt"` + Message string `json:"message"` +} + +// ExtractAiCodeCommits parses raw AI code commit records into tool-layer tables. +func ExtractAiCodeCommits(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*CursorTaskData) + if !ok { + return errors.Default.New("task data is not CursorTaskData") + } + if data.Connection.KeyTier != models.KeyTierEnterprise { + taskCtx.GetLogger().Info("Skipping AI code commit extraction: key tier is %q, not enterprise", data.Connection.KeyTier) + return nil + } + + extractor, err := newCursorStatefulExtractor(&cursorStatefulExtractorArgs[aiCodeCommitRecord]{ + SubtaskCommonArgs: cursorSubtaskCommonArgs(taskCtx, data, rawAiCodeCommitsTable), + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + ToolTable: models.CursorAiCodeCommit{}.TableName(), + Extract: func(record *aiCodeCommitRecord, row *helper.RawData) ([]any, errors.Error) { + commitHash := strings.TrimSpace(record.CommitHash) + if commitHash == "" { + return nil, nil + } + + commit := &models.CursorAiCodeCommit{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + CommitHash: commitHash, + UserId: strings.TrimSpace(record.UserId), + UserEmail: strings.TrimSpace(record.UserEmail), + RepoName: strings.TrimSpace(record.RepoName), + BranchName: strings.TrimSpace(record.BranchName), + IsPrimaryBranch: record.IsPrimaryBranch, + CommitSource: strings.TrimSpace(record.CommitSource), + TotalLinesAdded: record.TotalLinesAdded, + TotalLinesDeleted: record.TotalLinesDeleted, + TabLinesAdded: record.TabLinesAdded, + TabLinesDeleted: record.TabLinesDeleted, + ComposerLinesAdded: record.ComposerLinesAdded, + ComposerLinesDeleted: record.ComposerLinesDeleted, + NonAiLinesAdded: record.NonAiLinesAdded, + NonAiLinesDeleted: record.NonAiLinesDeleted, + Message: strings.TrimSpace(record.Message), + } + if ts := parseOptionalISOTime(record.CommitTs); ts != nil { + commit.CommitTs = ts + } + if ts := parseOptionalISOTime(record.CreatedAt); ts != nil { + commit.IngestedAt = ts + } + return []any{commit}, nil + }, + }) + if err != nil { + return err + } + return extractor.Execute() +} + +func parseOptionalISOTime(raw string) *time.Time { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + for _, layout := range []string{ + time.RFC3339, + time.RFC3339Nano, + "2006-01-02T15:04:05Z", + "2006-01-02 15:04:05.000000", + "2006-01-02 15:04:05.000", + "2006-01-02 15:04:05", + "2006-01-02", + } { + if t, err := time.Parse(layout, raw); err == nil { + t = t.UTC() + return &t + } + } + return nil +} diff --git a/backend/plugins/cursor/tasks/ai_code_test.go b/backend/plugins/cursor/tasks/ai_code_test.go new file mode 100644 index 00000000000..9937a311ab9 --- /dev/null +++ b/backend/plugins/cursor/tasks/ai_code_test.go @@ -0,0 +1,153 @@ +/* +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" + "testing" + "time" +) + +func TestParseOptionalISOTime(t *testing.T) { + tests := []struct { + name string + input string + isNil bool + want string + }{ + {name: "RFC3339", input: "2026-08-20T14:30:00Z", want: "2026-08-20T14:30:00Z"}, + {name: "RFC3339 with offset", input: "2026-08-20T08:30:00-06:00", want: "2026-08-20T14:30:00Z"}, + {name: "RFC3339Nano", input: "2026-08-20T14:30:00.123456789Z", want: "2026-08-20T14:30:00Z"}, + {name: "MySQL datetime", input: "2026-08-27 16:18:49.000", want: "2026-08-27T16:18:49Z"}, + {name: "MySQL datetime with ms", input: "2026-08-27 16:18:56.608", want: "2026-08-27T16:18:56Z"}, + {name: "empty string", input: "", isNil: true}, + {name: "whitespace", input: " ", isNil: true}, + {name: "garbage", input: "not-a-date", isNil: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseOptionalISOTime(tt.input) + if tt.isNil { + if result != nil { + t.Fatalf("expected nil, got %v", *result) + } + return + } + if result == nil { + t.Fatalf("expected non-nil result for %q", tt.input) + } + got := result.UTC().Format(time.RFC3339) + if got != tt.want { + t.Fatalf("got %s, want %s", got, tt.want) + } + }) + } +} + +func TestSplitAnalyticsDateRange(t *testing.T) { + start := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC).UnixMilli() + end := time.Date(2026, 8, 25, 0, 0, 0, 0, time.UTC).UnixMilli() + + chunks := splitAnalyticsDateRange(start, end, 30) + if len(chunks) == 0 { + t.Fatal("expected at least one chunk") + } + if chunks[0].StartDate != "2026-07-01" { + t.Fatalf("first chunk start = %s, want 2026-07-01", chunks[0].StartDate) + } + if chunks[len(chunks)-1].EndDate != "2026-08-25" { + t.Fatalf("last chunk end = %s, want 2026-08-25", chunks[len(chunks)-1].EndDate) + } + if len(chunks) < 2 { + t.Fatalf("expected multiple chunks for a 55-day range, got %d", len(chunks)) + } +} + +func TestSplitAnalyticsDateRange_Empty(t *testing.T) { + now := time.Now().UTC().UnixMilli() + chunks := splitAnalyticsDateRange(now, now, 30) + if chunks != nil { + t.Fatalf("expected nil for equal start/end, got %d chunks", len(chunks)) + } +} + +func TestSplitAnalyticsDateRange_ShortRange(t *testing.T) { + start := time.Date(2026, 8, 20, 0, 0, 0, 0, time.UTC).UnixMilli() + end := time.Date(2026, 8, 25, 0, 0, 0, 0, time.UTC).UnixMilli() + + chunks := splitAnalyticsDateRange(start, end, 30) + if len(chunks) != 1 { + t.Fatalf("expected 1 chunk for 5-day range, got %d", len(chunks)) + } + if chunks[0].StartDate != "2026-08-20" || chunks[0].EndDate != "2026-08-25" { + t.Fatalf("chunk = %v, want 2026-08-20 to 2026-08-25", chunks[0]) + } +} + +func TestParseAiCodeTrackingItemsBody_PrefersItems(t *testing.T) { + body := []byte(`{"items":[{"commitHash":"abc"}],"totalCount":1,"pageSize":100}`) + items, err := parseAiCodeTrackingItemsBody(body) + if err != nil { + t.Fatalf("parseAiCodeTrackingItemsBody: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + var commit struct { + CommitHash string `json:"commitHash"` + } + if jsonErr := json.Unmarshal(items[0], &commit); jsonErr != nil { + t.Fatalf("unmarshal item: %v", jsonErr) + } + if commit.CommitHash != "abc" { + t.Fatalf("commitHash = %q, want abc", commit.CommitHash) + } +} + +func TestParseAiCodeTrackingItemsBody_FallsBackToData(t *testing.T) { + body := []byte(`{"data":[{"changeId":"123"}]}`) + items, err := parseAiCodeTrackingItemsBody(body) + if err != nil { + t.Fatalf("parseAiCodeTrackingItemsBody: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } +} + +func TestParseAiCodeTrackingTotalPages_FromTotalCount(t *testing.T) { + body := []byte(`{"items":[],"totalCount":250,"pageSize":100}`) + pages, err := parseAiCodeTrackingTotalPages(body, 100) + if err != nil { + t.Fatalf("parseAiCodeTrackingTotalPages: %v", err) + } + if pages != 3 { + t.Fatalf("pages = %d, want 3", pages) + } +} + +func TestParseAiCodeTrackingTotalPages_FromTotalPages(t *testing.T) { + body := []byte(`{"items":[],"totalPages":5}`) + pages, err := parseAiCodeTrackingTotalPages(body, 100) + if err != nil { + t.Fatalf("parseAiCodeTrackingTotalPages: %v", err) + } + if pages != 5 { + t.Fatalf("pages = %d, want 5", pages) + } +} diff --git a/backend/plugins/cursor/tasks/bugbot_reviews_collector.go b/backend/plugins/cursor/tasks/bugbot_reviews_collector.go new file mode 100644 index 00000000000..42faaae7823 --- /dev/null +++ b/backend/plugins/cursor/tasks/bugbot_reviews_collector.go @@ -0,0 +1,117 @@ +/* +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" + "fmt" + "net/http" + "net/url" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" +) + +const rawBugbotReviewsTable = "cursor_bugbot_reviews" + +// CollectBugbotReviews collects completed BugBot reviews from +// GET /analytics/team/bugbot-reviews. +func CollectBugbotReviews(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*CursorTaskData) + if !ok { + return errors.Default.New("task data is not CursorTaskData") + } + if !data.Connection.HasBugbotReviews { + taskCtx.GetLogger().Info("Skipping BugBot review collection: connection lacks bugbot-reviews access") + return nil + } + + apiClient, err := CreateApiClient(taskCtx.TaskContext(), data.Connection) + if err != nil { + return err + } + + rawArgs := helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawBugbotReviewsTable, + Options: rawParamsFromTaskData(data), + } + + collector, err := helper.NewStatefulApiCollector(rawArgs) + if err != nil { + return err + } + + dateRangeIter := newAnalyticsDateRangeIterator(collector.GetSince(), collector.IsIncremental()) + + err = collector.InitCollector(helper.ApiCollectorArgs{ + ApiClient: apiClient, + Input: dateRangeIter, + UrlTemplate: "analytics/team/bugbot-reviews", + Query: func(reqData *helper.RequestData) (url.Values, errors.Error) { + input := reqData.Input.(analyticsDateRangeInput) + q := url.Values{} + q.Set("startDate", input.StartDate) + q.Set("endDate", input.EndDate) + q.Set("page", fmt.Sprintf("%d", reqData.Pager.Page)) + q.Set("pageSize", fmt.Sprintf("%d", reqData.Pager.Size)) + return q, nil + }, + PageSize: cursorApiPageSize, + GetTotalPages: func(res *http.Response, args *helper.ApiCollectorArgs) (int, errors.Error) { + body, readErr := readResponseBody(res) + if readErr != nil { + return 0, readErr + } + var pagination struct { + Pagination struct { + TotalPages int `json:"totalPages"` + } `json:"pagination"` + } + if jsonErr := json.Unmarshal(body, &pagination); jsonErr != nil { + return 0, errors.Default.Wrap(errors.Convert(jsonErr), "failed to parse bugbot-reviews pagination") + } + if pagination.Pagination.TotalPages <= 0 { + return 1, nil + } + return pagination.Pagination.TotalPages, nil + }, + ResponseParser: parseAnalyticsDataArrayResponse, + }) + if err != nil { + return err + } + + logUsageCollectionWindow(taskCtx.GetLogger(), "analytics/team/bugbot-reviews", collector.GetSince(), collector.IsIncremental()) + return collector.Execute() +} + +func parseAnalyticsDataArrayResponse(res *http.Response) ([]json.RawMessage, errors.Error) { + body, err := readResponseBody(res) + if err != nil { + return nil, err + } + var response struct { + Data []json.RawMessage `json:"data"` + } + if jsonErr := json.Unmarshal(body, &response); jsonErr != nil { + return nil, errors.Default.Wrap(errors.Convert(jsonErr), "failed to decode analytics response") + } + return response.Data, nil +} diff --git a/backend/plugins/cursor/tasks/bugbot_reviews_extractor.go b/backend/plugins/cursor/tasks/bugbot_reviews_extractor.go new file mode 100644 index 00000000000..ad47e3ecc16 --- /dev/null +++ b/backend/plugins/cursor/tasks/bugbot_reviews_extractor.go @@ -0,0 +1,131 @@ +/* +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" + "strings" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/cursor/models" +) + +type bugbotFindingRecord struct { + CommentId *string `json:"comment_id"` + ResolutionStatus *string `json:"resolution_status"` + Severity *string `json:"severity"` + Title *string `json:"title"` + Description *string `json:"description"` + Locations json.RawMessage `json:"locations"` +} + +type bugbotReviewRecord struct { + RequestId string `json:"request_id"` + Timestamp string `json:"timestamp"` + Repo string `json:"repo"` + RepoNodeId *string `json:"repo_node_id"` + PrNumber *int `json:"pr_number"` + CommitSha *string `json:"commit_sha"` + BugsFound int `json:"bugs_found"` + CostCents *float64 `json:"cost_cents"` + DryRun bool `json:"dry_run"` + PublicationStatus string `json:"publication_status"` + Bugs []bugbotFindingRecord `json:"bugs"` +} + +// ExtractBugbotReviews parses raw BugBot review records into review and finding tool tables. +func ExtractBugbotReviews(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*CursorTaskData) + if !ok { + return errors.Default.New("task data is not CursorTaskData") + } + if !data.Connection.HasBugbotReviews { + taskCtx.GetLogger().Info("Skipping BugBot review extraction: connection lacks bugbot-reviews access") + return nil + } + + extractor, err := newCursorStatefulExtractor(&cursorStatefulExtractorArgs[bugbotReviewRecord]{ + SubtaskCommonArgs: cursorSubtaskCommonArgs(taskCtx, data, rawBugbotReviewsTable), + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + ToolTable: models.CursorBugBotReview{}.TableName(), + Extract: func(record *bugbotReviewRecord, row *helper.RawData) ([]any, errors.Error) { + requestId := strings.TrimSpace(record.RequestId) + if requestId == "" { + return nil, nil + } + + review := &models.CursorBugBotReview{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + RequestId: requestId, + Repo: strings.TrimSpace(record.Repo), + PrNumber: record.PrNumber, + BugsFound: record.BugsFound, + CostCents: record.CostCents, + DryRun: record.DryRun, + PublicationStatus: strings.TrimSpace(record.PublicationStatus), + } + if ts := parseOptionalISOTime(record.Timestamp); ts != nil { + review.Timestamp = ts + } + if record.RepoNodeId != nil { + review.RepoNodeId = strings.TrimSpace(*record.RepoNodeId) + } + if record.CommitSha != nil { + review.CommitSha = strings.TrimSpace(*record.CommitSha) + } + + results := []any{review} + for i, bug := range record.Bugs { + finding := &models.CursorBugBotFinding{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + RequestId: requestId, + FindingIndex: i, + } + if bug.CommentId != nil { + finding.CommentId = strings.TrimSpace(*bug.CommentId) + } + if bug.ResolutionStatus != nil { + finding.ResolutionStatus = strings.TrimSpace(*bug.ResolutionStatus) + } + if bug.Severity != nil { + finding.Severity = strings.TrimSpace(*bug.Severity) + } + if bug.Title != nil { + finding.Title = strings.TrimSpace(*bug.Title) + } + if bug.Description != nil { + finding.Description = strings.TrimSpace(*bug.Description) + } + if len(bug.Locations) > 0 && string(bug.Locations) != "null" { + finding.Locations = string(bug.Locations) + } + results = append(results, finding) + } + return results, nil + }, + }) + if err != nil { + return err + } + return extractor.Execute() +} diff --git a/backend/plugins/cursor/tasks/collector_utils.go b/backend/plugins/cursor/tasks/collector_utils.go index e5b382f959a..6d7c7c308d9 100644 --- a/backend/plugins/cursor/tasks/collector_utils.go +++ b/backend/plugins/cursor/tasks/collector_utils.go @@ -30,6 +30,7 @@ import ( "github.com/apache/devlake/core/errors" "github.com/apache/devlake/core/log" + helper "github.com/apache/devlake/helpers/pluginhelper/api" "github.com/apache/devlake/plugins/cursor/models" ) @@ -209,6 +210,60 @@ func parseDailyUsageResponse(res *http.Response) ([]json.RawMessage, errors.Erro return response.Data, nil } +type aiCodeTrackingListResponse struct { + Items []json.RawMessage `json:"items"` + Data []json.RawMessage `json:"data"` + TotalCount int `json:"totalCount"` + PageSize int `json:"pageSize"` + TotalPages int `json:"totalPages"` +} + +// parseAiCodeTrackingItemsResponse decodes AI Code Tracking list payloads. +// Cursor docs use "items"; some responses may still expose "data". +func parseAiCodeTrackingItemsResponse(res *http.Response) ([]json.RawMessage, errors.Error) { + body, err := readResponseBody(res) + if err != nil { + return nil, err + } + return parseAiCodeTrackingItemsBody(body) +} + +func parseAiCodeTrackingItemsBody(body []byte) ([]json.RawMessage, errors.Error) { + var response aiCodeTrackingListResponse + if jsonErr := json.Unmarshal(body, &response); jsonErr != nil { + return nil, errors.Default.Wrap(errors.Convert(jsonErr), "failed to decode ai-code tracking response") + } + if len(response.Items) > 0 { + return response.Items, nil + } + return response.Data, nil +} + +func parseAiCodeTrackingTotalPages(body []byte, defaultPageSize int) (int, errors.Error) { + var response aiCodeTrackingListResponse + if jsonErr := json.Unmarshal(body, &response); jsonErr != nil { + return 0, errors.Default.Wrap(errors.Convert(jsonErr), "failed to decode ai-code tracking pagination") + } + if response.TotalPages > 0 { + return response.TotalPages, nil + } + if response.TotalCount <= 0 { + return 1, nil + } + pageSize := response.PageSize + if pageSize <= 0 { + pageSize = defaultPageSize + } + if pageSize <= 0 { + pageSize = 1 + } + totalPages := (response.TotalCount + pageSize - 1) / pageSize + if totalPages <= 0 { + return 1, nil + } + return totalPages, nil +} + func parseMembersResponse(res *http.Response) ([]json.RawMessage, errors.Error) { body, err := readResponseBody(res) if err != nil { @@ -271,6 +326,16 @@ func computeEventId(timestamp, userEmail, conversationId, model string, chargedC return hex.EncodeToString(sum[:]) } +func ignoreHTTPStatus404(res *http.Response) errors.Error { + if res.StatusCode == http.StatusUnauthorized { + return errors.Unauthorized.New("authentication failed, please check your AccessToken") + } + if res.StatusCode == http.StatusNotFound { + return helper.ErrIgnoreAndContinue + } + return nil +} + func normalizeNullableString(value string) string { value = strings.TrimSpace(value) if value == "" || strings.EqualFold(value, "null") { diff --git a/backend/plugins/cursor/tasks/conversation_insights_collector.go b/backend/plugins/cursor/tasks/conversation_insights_collector.go new file mode 100644 index 00000000000..a7da88df21b --- /dev/null +++ b/backend/plugins/cursor/tasks/conversation_insights_collector.go @@ -0,0 +1,105 @@ +/* +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" + "net/http" + "net/url" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/cursor/models" +) + +const ( + rawConversationInsightsTable = "cursor_conversation_insights" + conversationInsightsIncludeParam = "intents,complexity,categories,guidanceLevels,workTypes" +) + +// CollectConversationInsights collects aggregate Conversation Insights from +// GET /analytics/team/conversation-insights (Enterprise only). +func CollectConversationInsights(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*CursorTaskData) + if !ok { + return errors.Default.New("task data is not CursorTaskData") + } + if data.Connection.KeyTier != models.KeyTierEnterprise { + taskCtx.GetLogger().Info("Skipping Conversation Insights collection: key tier is %q, not enterprise", data.Connection.KeyTier) + return nil + } + if !data.Connection.HasConversationInsights { + taskCtx.GetLogger().Info("Skipping Conversation Insights collection: connection lacks conversation-insights access") + return nil + } + + apiClient, err := CreateApiClient(taskCtx.TaskContext(), data.Connection) + if err != nil { + return err + } + + rawArgs := helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawConversationInsightsTable, + Options: rawParamsFromTaskData(data), + } + + collector, err := helper.NewStatefulApiCollector(rawArgs) + if err != nil { + return err + } + + dateRangeIter := newAnalyticsDateRangeIterator(collector.GetSince(), collector.IsIncremental()) + + err = collector.InitCollector(helper.ApiCollectorArgs{ + ApiClient: apiClient, + Input: dateRangeIter, + UrlTemplate: "analytics/team/conversation-insights", + Query: func(reqData *helper.RequestData) (url.Values, errors.Error) { + input := reqData.Input.(analyticsDateRangeInput) + q := url.Values{} + q.Set("startDate", input.StartDate) + q.Set("endDate", input.EndDate) + q.Set("include", conversationInsightsIncludeParam) + return q, nil + }, + PageSize: 1, + GetTotalPages: func(_ *http.Response, _ *helper.ApiCollectorArgs) (int, errors.Error) { + return 1, nil + }, + ResponseParser: parseConversationInsightsResponse, + }) + if err != nil { + return err + } + + logUsageCollectionWindow(taskCtx.GetLogger(), "analytics/team/conversation-insights", collector.GetSince(), collector.IsIncremental()) + return collector.Execute() +} + +func parseConversationInsightsResponse(res *http.Response) ([]json.RawMessage, errors.Error) { + body, err := readResponseBody(res) + if err != nil { + return nil, err + } + if len(body) == 0 { + return nil, nil + } + return []json.RawMessage{body}, nil +} diff --git a/backend/plugins/cursor/tasks/conversation_insights_extractor.go b/backend/plugins/cursor/tasks/conversation_insights_extractor.go new file mode 100644 index 00000000000..75575188b72 --- /dev/null +++ b/backend/plugins/cursor/tasks/conversation_insights_extractor.go @@ -0,0 +1,346 @@ +/* +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 ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/apache/devlake/core/errors" + "github.com/apache/devlake/core/plugin" + helper "github.com/apache/devlake/helpers/pluginhelper/api" + "github.com/apache/devlake/plugins/cursor/models" +) + +type conversationInsightsPayload struct { + Data conversationInsightsData `json:"data"` + Params conversationInsightsParams `json:"params"` +} + +type conversationInsightsData struct { + Intents *conversationInsightBlock `json:"intents"` + Complexity *conversationInsightBlock `json:"complexity"` + Categories *conversationInsightBlock `json:"categories"` + GuidanceLevels *conversationInsightBlock `json:"guidanceLevels"` + WorkTypes *conversationInsightBlock `json:"workTypes"` +} + +type conversationInsightsParams struct { + StartDate string `json:"startDate"` + EndDate string `json:"endDate"` +} + +type conversationInsightBlock struct { + Distribution []json.RawMessage `json:"distribution"` + TopValues []json.RawMessage `json:"topValues"` + TimeSeries []json.RawMessage `json:"timeSeries"` + Subcategories map[string][]json.RawMessage `json:"subcategories"` +} + +type insightSpec struct { + insightType string + valueField string + timeField string + block *conversationInsightBlock + includeTop bool + includeSubcat bool +} + +// ExtractConversationInsights parses raw Conversation Insights payloads into tool-layer rows. +func ExtractConversationInsights(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*CursorTaskData) + if !ok { + return errors.Default.New("task data is not CursorTaskData") + } + if data.Connection.KeyTier != models.KeyTierEnterprise { + taskCtx.GetLogger().Info("Skipping Conversation Insights extraction: key tier is %q, not enterprise", data.Connection.KeyTier) + return nil + } + if !data.Connection.HasConversationInsights { + taskCtx.GetLogger().Info("Skipping Conversation Insights extraction: connection lacks conversation-insights access") + return nil + } + + extractor, err := newCursorStatefulExtractor(&cursorStatefulExtractorArgs[conversationInsightsPayload]{ + SubtaskCommonArgs: cursorSubtaskCommonArgs(taskCtx, data, rawConversationInsightsTable), + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + ToolTable: models.CursorConversationInsight{}.TableName(), + Extract: func(record *conversationInsightsPayload, _ *helper.RawData) ([]any, errors.Error) { + periodStart, periodEnd, err := parseConversationInsightPeriod(record.Params.StartDate, record.Params.EndDate) + if err != nil { + return nil, err + } + + specs := []insightSpec{ + { + insightType: models.ConversationInsightTypeIntents, + valueField: "intent", + timeField: "intent", + block: record.Data.Intents, + includeTop: true, + includeSubcat: true, + }, + { + insightType: models.ConversationInsightTypeComplexity, + valueField: "complexity", + timeField: "complexity", + block: record.Data.Complexity, + }, + { + insightType: models.ConversationInsightTypeCategories, + valueField: "category", + timeField: "category", + block: record.Data.Categories, + }, + { + insightType: models.ConversationInsightTypeGuidanceLevels, + valueField: "guidanceLevel", + timeField: "guidanceLevel", + block: record.Data.GuidanceLevels, + }, + { + insightType: models.ConversationInsightTypeWorkTypes, + valueField: "workType", + timeField: "workType", + block: record.Data.WorkTypes, + }, + } + + var rows []any + for _, spec := range specs { + if spec.block == nil { + continue + } + extracted, extractErr := flattenConversationInsightBlock( + data.Options.ConnectionId, + data.Options.ScopeId, + periodStart, + periodEnd, + spec, + ) + if extractErr != nil { + return nil, extractErr + } + rows = append(rows, extracted...) + } + return rows, nil + }, + }) + if err != nil { + return err + } + return extractor.Execute() +} + +func flattenConversationInsightBlock( + connectionId uint64, + scopeId string, + periodStart, periodEnd time.Time, + spec insightSpec, +) ([]any, errors.Error) { + var rows []any + + appendRows := func(aggregationType, dimensionValue, subcategoryGroup, metricDateKey string, metricDate *time.Time, count int) errors.Error { + dimensionValue = strings.TrimSpace(dimensionValue) + if dimensionValue == "" || count < 0 { + return nil + } + row := &models.CursorConversationInsight{ + ConnectionId: connectionId, + ScopeId: scopeId, + RecordId: computeConversationInsightRecordId( + periodStart, periodEnd, spec.insightType, aggregationType, + dimensionValue, subcategoryGroup, metricDateKey, + ), + PeriodStart: periodStart, + PeriodEnd: periodEnd, + InsightType: spec.insightType, + AggregationType: aggregationType, + DimensionValue: dimensionValue, + SubcategoryGroup: subcategoryGroup, + MetricDate: metricDate, + Count: count, + } + rows = append(rows, row) + return nil + } + + for _, item := range spec.block.Distribution { + value, count, err := parseConversationInsightCountItem(item, spec.valueField) + if err != nil { + return nil, err + } + if err := appendRows(models.ConversationInsightAggDistribution, value, "", "", nil, count); err != nil { + return nil, err + } + } + + if spec.includeTop { + for _, item := range spec.block.TopValues { + value, count, err := parseConversationInsightCountItem(item, spec.valueField) + if err != nil { + return nil, err + } + if err := appendRows(models.ConversationInsightAggTopValues, value, "", "", nil, count); err != nil { + return nil, err + } + } + } + + for _, item := range spec.block.TimeSeries { + value, metricDate, count, err := parseConversationInsightTimeSeriesItem(item, spec.timeField) + if err != nil { + return nil, err + } + metricDateKey := "" + if metricDate != nil { + metricDateKey = metricDate.Format("2006-01-02") + } + if err := appendRows(models.ConversationInsightAggTimeSeries, value, "", metricDateKey, metricDate, count); err != nil { + return nil, err + } + } + + if spec.includeSubcat { + for group, items := range spec.block.Subcategories { + for _, item := range items { + value, count, err := parseConversationInsightCountItem(item, "subcategory") + if err != nil { + return nil, err + } + if err := appendRows(models.ConversationInsightAggSubcategory, value, group, "", nil, count); err != nil { + return nil, err + } + } + } + } + + return rows, nil +} + +func parseConversationInsightPeriod(startDate, endDate string) (time.Time, time.Time, errors.Error) { + start, err := parseConversationInsightDate(startDate) + if err != nil { + return time.Time{}, time.Time{}, err + } + end, err := parseConversationInsightDate(endDate) + if err != nil { + return time.Time{}, time.Time{}, err + } + if end.Before(start) { + return time.Time{}, time.Time{}, errors.BadInput.New("conversation insights period end is before start") + } + return start, end, nil +} + +func parseConversationInsightDate(raw string) (time.Time, errors.Error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, errors.BadInput.New("conversation insights period date is empty") + } + if ts, err := time.Parse("2006-01-02", raw); err == nil { + return ts.UTC(), nil + } + return time.Time{}, errors.BadInput.New(fmt.Sprintf("invalid conversation insights period date %q", raw)) +} + +func parseConversationInsightCountItem(raw json.RawMessage, valueField string) (string, int, errors.Error) { + var item map[string]json.RawMessage + if err := json.Unmarshal(raw, &item); err != nil { + return "", 0, errors.Default.Wrap(errors.Convert(err), "failed to decode conversation insight count item") + } + value, count, err := parseConversationInsightFields(item, valueField) + if err != nil { + return "", 0, err + } + return value, count, nil +} + +func parseConversationInsightTimeSeriesItem(raw json.RawMessage, valueField string) (string, *time.Time, int, errors.Error) { + var item map[string]json.RawMessage + if err := json.Unmarshal(raw, &item); err != nil { + return "", nil, 0, errors.Default.Wrap(errors.Convert(err), "failed to decode conversation insight time series item") + } + value, count, err := parseConversationInsightFields(item, valueField) + if err != nil { + return "", nil, 0, err + } + dateRaw, err := decodeJSONString(item["date"]) + if err != nil { + return "", nil, 0, err + } + metricDate, err := parseConversationInsightDate(dateRaw) + if err != nil { + return "", nil, 0, err + } + return value, &metricDate, count, nil +} + +func parseConversationInsightFields(item map[string]json.RawMessage, valueField string) (string, int, errors.Error) { + valueRaw, ok := item[valueField] + if !ok { + return "", 0, errors.BadInput.New(fmt.Sprintf("conversation insight item missing %q field", valueField)) + } + value, err := decodeJSONString(valueRaw) + if err != nil { + return "", 0, err + } + countRaw, ok := item["count"] + if !ok { + return "", 0, errors.BadInput.New("conversation insight item missing count field") + } + var count int + if err := json.Unmarshal(countRaw, &count); err != nil { + return "", 0, errors.Default.Wrap(errors.Convert(err), "failed to decode conversation insight count") + } + return value, count, nil +} + +func decodeJSONString(raw json.RawMessage) (string, errors.Error) { + if len(raw) == 0 { + return "", errors.BadInput.New("conversation insight string field is empty") + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return "", errors.Default.Wrap(errors.Convert(err), "failed to decode conversation insight string field") + } + return strings.TrimSpace(value), nil +} + +func computeConversationInsightRecordId( + periodStart, periodEnd time.Time, + insightType, aggregationType, dimensionValue, subcategoryGroup, metricDateKey string, +) string { + payload := fmt.Sprintf( + "%s|%s|%s|%s|%s|%s|%s", + periodStart.Format("2006-01-02"), + periodEnd.Format("2006-01-02"), + insightType, + aggregationType, + dimensionValue, + subcategoryGroup, + metricDateKey, + ) + sum := sha256.Sum256([]byte(payload)) + return hex.EncodeToString(sum[:16]) +} diff --git a/backend/plugins/cursor/tasks/conversation_insights_extractor_test.go b/backend/plugins/cursor/tasks/conversation_insights_extractor_test.go new file mode 100644 index 00000000000..7e8494411d3 --- /dev/null +++ b/backend/plugins/cursor/tasks/conversation_insights_extractor_test.go @@ -0,0 +1,152 @@ +/* +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" + "testing" +) + +const sampleConversationInsightsResponse = `{ + "data": { + "intents": { + "distribution": [ + {"intent": "Write Code", "count": 18}, + {"intent": "Ask", "count": 7} + ], + "topValues": [ + {"intent": "Write Code", "count": 18} + ], + "timeSeries": [ + {"date": "2026-03-01", "intent": "Ask", "count": 2}, + {"date": "2026-03-02", "intent": "Write Code", "count": 6} + ], + "subcategories": { + "askMode": [ + {"subcategory": "error_fix", "count": 4} + ] + } + }, + "complexity": { + "distribution": [ + {"complexity": "high", "count": 12} + ], + "timeSeries": [ + {"date": "2026-03-02", "complexity": "high", "count": 5} + ] + }, + "categories": { + "distribution": [ + {"category": "New Features", "count": 9} + ] + }, + "guidanceLevels": { + "distribution": [ + {"guidanceLevel": "high", "count": 8} + ] + }, + "workTypes": { + "distribution": [ + {"workType": "new_feature", "count": 9} + ] + } + }, + "params": { + "metric": "conversation-insights", + "startDate": "2026-03-01", + "endDate": "2026-03-07" + } +}` + +func TestExtractConversationInsightsPayload(t *testing.T) { + var payload conversationInsightsPayload + if err := json.Unmarshal([]byte(sampleConversationInsightsResponse), &payload); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + + periodStart, periodEnd, err := parseConversationInsightPeriod(payload.Params.StartDate, payload.Params.EndDate) + if err != nil { + t.Fatalf("parse period: %v", err) + } + + specs := []insightSpec{ + { + insightType: "intents", + valueField: "intent", + timeField: "intent", + block: payload.Data.Intents, + includeTop: true, + includeSubcat: true, + }, + { + insightType: "complexity", + valueField: "complexity", + timeField: "complexity", + block: payload.Data.Complexity, + }, + { + insightType: "categories", + valueField: "category", + timeField: "category", + block: payload.Data.Categories, + }, + { + insightType: "guidanceLevels", + valueField: "guidanceLevel", + timeField: "guidanceLevel", + block: payload.Data.GuidanceLevels, + }, + { + insightType: "workTypes", + valueField: "workType", + timeField: "workType", + block: payload.Data.WorkTypes, + }, + } + + var total int + for _, spec := range specs { + rows, extractErr := flattenConversationInsightBlock(1, "team", periodStart, periodEnd, spec) + if extractErr != nil { + t.Fatalf("flatten %s: %v", spec.insightType, extractErr) + } + total += len(rows) + } + if total != 0 && total < 10 { + t.Fatalf("expected at least 10 insight rows, got %d", total) + } +} + +func TestComputeConversationInsightRecordIdStable(t *testing.T) { + start, err := parseConversationInsightDate("2026-03-01") + if err != nil { + t.Fatalf("parse start: %v", err) + } + end, err := parseConversationInsightDate("2026-03-07") + if err != nil { + t.Fatalf("parse end: %v", err) + } + first := computeConversationInsightRecordId(start, end, "intents", "distribution", "Write Code", "", "") + second := computeConversationInsightRecordId(start, end, "intents", "distribution", "Write Code", "", "") + if first != second { + t.Fatalf("record id should be stable") + } + if first == "" { + t.Fatal("record id should not be empty") + } +} diff --git a/backend/plugins/cursor/tasks/register.go b/backend/plugins/cursor/tasks/register.go index f802f0c9054..dc40896d2ec 100644 --- a/backend/plugins/cursor/tasks/register.go +++ b/backend/plugins/cursor/tasks/register.go @@ -30,5 +30,15 @@ func GetSubTaskMetas() []plugin.SubTaskMeta { ExtractUserSpendMeta, CollectDailyUsageMeta, ExtractDailyUsageMeta, + CollectAiCodeCommitsMeta, + ExtractAiCodeCommitsMeta, + CollectAiCodeCommitDetailsMeta, + ExtractAiCodeCommitDetailsMeta, + CollectAiCodeChangesMeta, + ExtractAiCodeChangesMeta, + CollectBugbotReviewsMeta, + ExtractBugbotReviewsMeta, + CollectConversationInsightsMeta, + ExtractConversationInsightsMeta, } } diff --git a/backend/plugins/cursor/tasks/subtasks.go b/backend/plugins/cursor/tasks/subtasks.go index a56461910c0..d30adeece4f 100644 --- a/backend/plugins/cursor/tasks/subtasks.go +++ b/backend/plugins/cursor/tasks/subtasks.go @@ -86,3 +86,89 @@ var ExtractDailyUsageMeta = plugin.SubTaskMeta{ Description: "Extract per-user per-day adoption metrics into tool-layer tables", Dependencies: []*plugin.SubTaskMeta{&CollectDailyUsageMeta}, } + +var CollectAiCodeCommitsMeta = plugin.SubTaskMeta{ + Name: "collectAiCodeCommits", + EntryPoint: CollectAiCodeCommits, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Collect per-commit AI line attribution from the Cursor Enterprise API (skipped for non-enterprise keys)", +} + +var ExtractAiCodeCommitsMeta = plugin.SubTaskMeta{ + Name: "extractAiCodeCommits", + EntryPoint: ExtractAiCodeCommits, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Extract per-commit AI line attribution into tool-layer tables", + Dependencies: []*plugin.SubTaskMeta{&CollectAiCodeCommitsMeta}, +} + +var CollectAiCodeCommitDetailsMeta = plugin.SubTaskMeta{ + Name: "collectAiCodeCommitDetails", + EntryPoint: CollectAiCodeCommitDetails, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Collect commit-detail blame and conversation metadata from the Cursor Enterprise API (skipped when commit-details is inaccessible)", + Dependencies: []*plugin.SubTaskMeta{&ExtractAiCodeCommitsMeta}, +} + +var ExtractAiCodeCommitDetailsMeta = plugin.SubTaskMeta{ + Name: "extractAiCodeCommitDetails", + EntryPoint: ExtractAiCodeCommitDetails, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Extract commit-detail blame and conversations into tool-layer tables", + Dependencies: []*plugin.SubTaskMeta{&CollectAiCodeCommitDetailsMeta}, +} + +var CollectAiCodeChangesMeta = plugin.SubTaskMeta{ + Name: "collectAiCodeChanges", + EntryPoint: CollectAiCodeChanges, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Collect granular accepted AI changes from the Cursor Enterprise API (skipped for non-enterprise keys)", +} + +var ExtractAiCodeChangesMeta = plugin.SubTaskMeta{ + Name: "extractAiCodeChanges", + EntryPoint: ExtractAiCodeChanges, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Extract granular accepted AI changes into tool-layer tables", + Dependencies: []*plugin.SubTaskMeta{&CollectAiCodeChangesMeta}, +} + +var CollectBugbotReviewsMeta = plugin.SubTaskMeta{ + Name: "collectBugbotReviews", + EntryPoint: CollectBugbotReviews, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Collect BugBot review analytics from the Cursor Analytics API (skipped when bugbot-reviews is inaccessible)", +} + +var ExtractBugbotReviewsMeta = plugin.SubTaskMeta{ + Name: "extractBugbotReviews", + EntryPoint: ExtractBugbotReviews, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Extract BugBot reviews and findings into tool-layer tables", + Dependencies: []*plugin.SubTaskMeta{&CollectBugbotReviewsMeta}, +} + +var CollectConversationInsightsMeta = plugin.SubTaskMeta{ + Name: "collectConversationInsights", + EntryPoint: CollectConversationInsights, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Collect Conversation Insights from the Cursor Analytics API (skipped for non-enterprise keys or when insights are disabled)", +} + +var ExtractConversationInsightsMeta = plugin.SubTaskMeta{ + Name: "extractConversationInsights", + EntryPoint: ExtractConversationInsights, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Extract Conversation Insights into tool-layer tables", + Dependencies: []*plugin.SubTaskMeta{&CollectConversationInsightsMeta}, +} diff --git a/backend/plugins/cursor/tasks/user_spend_extractor.go b/backend/plugins/cursor/tasks/user_spend_extractor.go index de9934982d0..bf9e11dbbd4 100644 --- a/backend/plugins/cursor/tasks/user_spend_extractor.go +++ b/backend/plugins/cursor/tasks/user_spend_extractor.go @@ -34,11 +34,35 @@ type spendMemberRecord struct { Role string `json:"role"` SpendCents float64 `json:"spendCents"` IncludedSpendCents float64 `json:"includedSpendCents"` + OverallSpendCents float64 `json:"overallSpendCents"` FastPremiumRequests int `json:"fastPremiumRequests"` MonthlyLimitDollars float64 `json:"monthlyLimitDollars"` HardLimitOverrideDollars float64 `json:"hardLimitOverrideDollars"` } +// normalizeUserSpendCents maps Cursor /teams/spend fields to on-demand and included +// amounts. Team plans return spendCents + includedSpendCents; Enterprise and newer +// tiered plans often omit includedSpendCents and expose overallSpendCents instead. +func normalizeUserSpendCents(spendCents, includedSpendCents, overallSpendCents float64) (onDemand, included float64) { + onDemand = spendCents + included = includedSpendCents + + if overallSpendCents <= 0 { + return onDemand, included + } + + derivedIncluded := overallSpendCents - spendCents + if derivedIncluded < 0 { + derivedIncluded = 0 + } + + if includedSpendCents > 0 { + return onDemand, includedSpendCents + } + + return onDemand, derivedIncluded +} + // ExtractUserSpend parses raw spend records into tool-layer tables. func ExtractUserSpend(taskCtx plugin.SubTaskContext) errors.Error { data, ok := taskCtx.TaskContext().GetData().(*CursorTaskData) @@ -71,6 +95,12 @@ func ExtractUserSpend(taskCtx plugin.SubTaskContext) errors.Error { return nil, nil } + onDemandCents, includedCents := normalizeUserSpendCents( + record.SpendCents, + record.IncludedSpendCents, + record.OverallSpendCents, + ) + spend := &models.CursorUserSpend{ ConnectionId: data.Options.ConnectionId, ScopeId: data.Options.ScopeId, @@ -80,8 +110,8 @@ func ExtractUserSpend(taskCtx plugin.SubTaskContext) errors.Error { Email: strings.TrimSpace(record.Email), Name: strings.TrimSpace(record.Name), Role: strings.TrimSpace(record.Role), - SpendCents: record.SpendCents, - IncludedSpendCents: record.IncludedSpendCents, + SpendCents: onDemandCents, + IncludedSpendCents: includedCents, FastPremiumRequests: record.FastPremiumRequests, MonthlyLimitDollars: record.MonthlyLimitDollars, HardLimitOverrideDollars: record.HardLimitOverrideDollars, diff --git a/backend/plugins/cursor/tasks/user_spend_extractor_test.go b/backend/plugins/cursor/tasks/user_spend_extractor_test.go new file mode 100644 index 00000000000..0afb277cc36 --- /dev/null +++ b/backend/plugins/cursor/tasks/user_spend_extractor_test.go @@ -0,0 +1,109 @@ +/* +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" + "math" + "testing" +) + +func TestNormalizeUserSpendCents(t *testing.T) { + tests := []struct { + name string + spendCents float64 + includedSpend float64 + overallSpend float64 + wantOnDemand float64 + wantIncluded float64 + }{ + { + name: "team legacy shape", + spendCents: 3224, + includedSpend: 1984, + wantOnDemand: 3224, + wantIncluded: 1984, + }, + { + name: "enterprise overall only", + spendCents: 0, + overallSpend: 1619.087826, + wantOnDemand: 0, + wantIncluded: 1619.087826, + }, + { + name: "tiered on-demand plus overall", + spendCents: 1875.500123, + overallSpend: 3200.750456, + wantOnDemand: 1875.500123, + wantIncluded: 1325.250333, + }, + { + name: "explicit included wins when both present", + spendCents: 100, + includedSpend: 50, + overallSpend: 200, + wantOnDemand: 100, + wantIncluded: 50, + }, + { + name: "all zero", + wantOnDemand: 0, + wantIncluded: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + onDemand, included := normalizeUserSpendCents(tt.spendCents, tt.includedSpend, tt.overallSpend) + if math.Abs(onDemand-tt.wantOnDemand) > 1e-6 { + t.Fatalf("onDemand = %v, want %v", onDemand, tt.wantOnDemand) + } + if math.Abs(included-tt.wantIncluded) > 1e-6 { + t.Fatalf("included = %v, want %v", included, tt.wantIncluded) + } + }) + } +} + +func TestSpendMemberRecordUnmarshalEnterpriseShape(t *testing.T) { + raw := []byte(`{ + "userId":"user_enterprise123", + "spendCents":0, + "overallSpendCents":1619.087826, + "fastPremiumRequests":0, + "name":"Enterprise User", + "email":"enterprise@example.com", + "role":"owner", + "monthlyLimitDollars":500, + "hardLimitOverrideDollars":0 + }`) + + var record spendMemberRecord + if err := json.Unmarshal(raw, &record); err != nil { + t.Fatalf("unmarshal spend member: %v", err) + } + + onDemand, included := normalizeUserSpendCents(record.SpendCents, record.IncludedSpendCents, record.OverallSpendCents) + if onDemand != 0 { + t.Fatalf("onDemand = %v, want 0", onDemand) + } + if included != 1619.087826 { + t.Fatalf("included = %v, want 1619.087826", included) + } +} diff --git a/backend/scripts/build-plugins.sh b/backend/scripts/build-plugins.sh index 3619e722653..41595ef8876 100755 --- a/backend/scripts/build-plugins.sh +++ b/backend/scripts/build-plugins.sh @@ -83,6 +83,11 @@ fi echo "Plugin build parallelism: $PARALLELISM" for PLUG in $PLUGINS; do NAME=$(basename $PLUG) + ROOT_GO_FILES=$(find "$PLUG" -maxdepth 1 -name '*.go' -print) + if [ -z "$ROOT_GO_FILES" ]; then + echo "Skipping plugin $NAME: no root-level *.go entrypoint in $PLUG" >&2 + continue + fi echo "Building plugin $NAME to bin/plugins/$NAME/$NAME.so with args: $* --gcflags="$GCFLAGS"" go build -buildmode=plugin --gcflags="$GCFLAGS" -o $PLUGIN_OUTPUT_DIR/$NAME/$NAME.so $PLUG/*.go & PIDS="$PIDS $!" diff --git a/config-ui/src/plugins/components/connection-form/index.tsx b/config-ui/src/plugins/components/connection-form/index.tsx index 80ebab38415..220063e63b5 100644 --- a/config-ui/src/plugins/components/connection-form/index.tsx +++ b/config-ui/src/plugins/components/connection-form/index.tsx @@ -107,6 +107,7 @@ export const ConnectionForm = ({ plugin, connectionId, onSuccess }: Props) => { const [values, setValues] = useState({}); const [errors, setErrors] = useState>({}); const [operating, setOperating] = useState(false); + const [testResult, setTestResult] = useState(null); const dispatch = useAppDispatch(); const connection = useAppSelector((state) => selectConnection(state, `${plugin}-${connectionId}`)); @@ -117,7 +118,14 @@ export const ConnectionForm = ({ plugin, connectionId, onSuccess }: Props) => { const pluginConfig = getPluginConfig(plugin); const name = pluginConfig?.name; - const { docLink = '', fields = [], initialValues = {} } = pluginConfig?.connection ?? {}; + const { + docLink = '', + fields = [], + initialValues = {}, + formatTestMessage, + onTestSuccess, + renderTestSummary, + } = pluginConfig?.connection ?? {}; const disabled = useMemo(() => { return Object.values(errors).some(Boolean); @@ -131,23 +139,53 @@ export const ConnectionForm = ({ plugin, connectionId, onSuccess }: Props) => { const handleTest = async () => { const isUpdate = type === 'update' && !!connectionId; - const [success, res] = await operator( - () => - isUpdate - ? API.connection.test( + const showPluginMessage = !!pluginConfig.connection.showTestResultMessage; + const usesCustomTestUi = !!(formatTestMessage || onTestSuccess || renderTestSummary); + let testResponse: any; + const [requestSuccess, res] = await operator( + async () => { + testResponse = isUpdate + ? await API.connection.test( plugin, connectionId, buildUpdateTestPayload(connection, values, sanitizedCustomHeaders), ) - : API.connection.testOld(plugin, buildCreateTestPayload(initialValues, values, sanitizedCustomHeaders)), + : await API.connection.testOld(plugin, buildCreateTestPayload(initialValues, values, sanitizedCustomHeaders)); + return testResponse; + }, { setOperating, formatMessage: () => 'Test Connection Successfully.', - hideToast: !!pluginConfig.connection.showTestResultMessage, + hideToast: usesCustomTestUi || showPluginMessage, }, ); - if (success && pluginConfig.connection.showTestResultMessage) { + if (!requestSuccess) { + setTestResult(null); + return; + } + + if (usesCustomTestUi && res) { + setTestResult(res); + const connectionOk = res.success !== false; + if (!renderTestSummary) { + const toastMessage = + formatTestMessage?.(res) ?? (connectionOk ? 'Test Connection Successfully.' : 'Test Connection Failed.'); + if (connectionOk) { + message.success(toastMessage); + } else { + message.error(toastMessage); + } + } + if (connectionOk) { + onTestSuccess?.(res, { + setValues: (patch) => setValues((prev) => ({ ...prev, ...patch })), + }); + } + return; + } + + if (showPluginMessage) { message.success(res?.message || 'Test Connection Successfully.'); } }; @@ -193,6 +231,7 @@ export const ConnectionForm = ({ plugin, connectionId, onSuccess }: Props) => { setValues={setValues} setErrors={setErrors} /> + {testResult && renderTestSummary?.(testResult)}