From eab23cbedab296849f89be238c19e898d5c1903d Mon Sep 17 00:00:00 2001 From: Joshua Smith Date: Tue, 25 Aug 2026 13:50:01 -0600 Subject: [PATCH 01/12] fix(cursor): Map Enterprise overallSpendCents spend fields * Derive included spend from overallSpendCents when the Enterprise API omits includedSpendCents. * Add unit and end-to-end fixtures covering team and Enterprise spend response shapes. Signed-off-by: Joshua Smith --- .../e2e/raw_tables/_raw_cursor_user_spend.csv | 1 + .../_tool_cursor_user_spend.csv | 1 + .../cursor/tasks/user_spend_extractor.go | 34 +++++- .../cursor/tasks/user_spend_extractor_test.go | 109 ++++++++++++++++++ 4 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 backend/plugins/cursor/tasks/user_spend_extractor_test.go 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_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/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) + } +} From 0b1099924aab298cbca49eff5b67124b84f15956 Mon Sep 17 00:00:00 2001 From: Joshua Smith Date: Wed, 26 Aug 2026 11:25:15 -0600 Subject: [PATCH 02/12] feat(cursor): add key tier and AI code tracking * Detect and store API key tier on create, update, and test, and show it in Config UI. * Collect Enterprise AI code commits and changes when the connection key tier is enterprise. Signed-off-by: Joshua Smith --- backend/plugins/cursor/README.md | 46 +++-- backend/plugins/cursor/api/connection.go | 11 ++ backend/plugins/cursor/api/test_connection.go | 17 +- .../cursor/e2e/ai_code_extractors_test.go | 118 +++++++++++++ .../_raw_cursor_ai_code_changes.csv | 2 + .../_raw_cursor_ai_code_commits.csv | 2 + .../_tool_cursor_ai_code_changes.csv | 2 + .../_tool_cursor_ai_code_commits.csv | 2 + .../plugins/cursor/models/ai_code_change.go | 47 ++++++ .../plugins/cursor/models/ai_code_commit.go | 55 ++++++ backend/plugins/cursor/models/connection.go | 17 +- .../migrationscripts/20260826_add_key_tier.go | 99 +++++++++++ .../models/migrationscripts/register.go | 1 + backend/plugins/cursor/models/models.go | 2 + .../plugins/cursor/service/connection_test.go | 91 ++++++++++ .../cursor/service/connection_test_helper.go | 74 +++++--- .../cursor/tasks/ai_code_changes_collector.go | 116 +++++++++++++ .../cursor/tasks/ai_code_changes_extractor.go | 91 ++++++++++ .../cursor/tasks/ai_code_commits_collector.go | 158 ++++++++++++++++++ .../cursor/tasks/ai_code_commits_extractor.go | 118 +++++++++++++ backend/plugins/cursor/tasks/ai_code_test.go | 98 +++++++++++ backend/plugins/cursor/tasks/register.go | 4 + backend/plugins/cursor/tasks/subtasks.go | 34 ++++ .../components/connection-form/index.tsx | 55 +++++- .../components/connection-list/index.tsx | 6 + .../src/plugins/register/cursor/config.tsx | 17 +- .../cursor/connection-fields/index.ts | 1 + .../cursor/connection-fields/key-tier.tsx | 58 +++++++ .../cursor/connection-fields/token.tsx | 8 +- .../register/cursor/connection-meta.tsx | 75 +++++++++ .../cursor/connection-test-summary.tsx | 130 ++++++++++++++ .../src/routes/connection/connection.tsx | 11 +- config-ui/src/types/connection.ts | 20 +++ config-ui/src/types/plugin.ts | 8 + 34 files changed, 1533 insertions(+), 61 deletions(-) create mode 100644 backend/plugins/cursor/e2e/ai_code_extractors_test.go create mode 100644 backend/plugins/cursor/e2e/raw_tables/_raw_cursor_ai_code_changes.csv create mode 100644 backend/plugins/cursor/e2e/raw_tables/_raw_cursor_ai_code_commits.csv create mode 100644 backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_ai_code_changes.csv create mode 100644 backend/plugins/cursor/e2e/snapshot_tables/_tool_cursor_ai_code_commits.csv create mode 100644 backend/plugins/cursor/models/ai_code_change.go create mode 100644 backend/plugins/cursor/models/ai_code_commit.go create mode 100644 backend/plugins/cursor/models/migrationscripts/20260826_add_key_tier.go create mode 100644 backend/plugins/cursor/tasks/ai_code_changes_collector.go create mode 100644 backend/plugins/cursor/tasks/ai_code_changes_extractor.go create mode 100644 backend/plugins/cursor/tasks/ai_code_commits_collector.go create mode 100644 backend/plugins/cursor/tasks/ai_code_commits_extractor.go create mode 100644 backend/plugins/cursor/tasks/ai_code_test.go create mode 100644 config-ui/src/plugins/register/cursor/connection-fields/key-tier.tsx create mode 100644 config-ui/src/plugins/register/cursor/connection-meta.tsx create mode 100644 config-ui/src/plugins/register/cursor/connection-test-summary.tsx diff --git a/backend/plugins/cursor/README.md b/backend/plugins/cursor/README.md index 30e43b8468f..f7994343ecf 100644 --- a/backend/plugins/cursor/README.md +++ b/backend/plugins/cursor/README.md @@ -31,6 +31,13 @@ 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/changes` | GET | Granular accepted AI changes with per-file metadata | + **Stored data (tool layer):** | Table | Description | @@ -39,6 +46,8 @@ 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_changes` | Accepted AI changes with source, model, and per-file metadata (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 +69,8 @@ flowchart LR 2. `collectUsageEvents` → `extractUsageEvents` 3. `collectUserSpend` → `extractUserSpend` 4. `collectDailyUsage` → `extractDailyUsage` +5. `collectAiCodeCommits` → `extractAiCodeCommits` *(Enterprise only -- skipped for Team keys)* +6. `collectAiCodeChanges` → `extractAiCodeChanges` *(Enterprise only -- skipped for Team keys)* ## Repository layout @@ -124,7 +135,7 @@ 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 @@ -149,30 +160,31 @@ 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 | - -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. +| 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/changes` | Granular accepted AI changes | `_tool_cursor_ai_code_changes` | -**Metrics this would unlock** (shown on the Cursor native dashboard but absent from DevLake today): +Team/Business Admin API keys receive **401** on these routes. `TestConnection` probes `/analytics/team/dau` and `/analytics/ai-code/commits` 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. Enterprise collectors check `KeyTier` at runtime and skip silently for non-enterprise keys. -- 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 +**Not yet collected:** -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. +| 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. Other Analytics endpoints (`/analytics/team/*`) are not yet collected (see [Enterprise AI Code Tracking](#enterprise-ai-code-tracking) 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_extractors_test.go b/backend/plugins/cursor/e2e/ai_code_extractors_test.go new file mode 100644 index 00000000000..599dae38a7f --- /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/incubator-devlake/core/config" + "github.com/apache/incubator-devlake/core/models/common" + "github.com/apache/incubator-devlake/core/runner" + "github.com/apache/incubator-devlake/helpers/e2ehelper" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/cursor/impl" + "github.com/apache/incubator-devlake/plugins/cursor/models" + "github.com/apache/incubator-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/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_commits.csv b/backend/plugins/cursor/e2e/raw_tables/_raw_cursor_ai_code_commits.csv new file mode 100644 index 00000000000..46e571bece0 --- /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""}",https://api.cursor.com/analytics/ai-code/commits,null,2026-08-20 14:31:00.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..9e2c07adcd3 --- /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,created_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..80ed0863b18 --- /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,created_at +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 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..cbdc35d60c2 --- /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/incubator-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"` + CreatedAt *time.Time `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..8a02afde974 --- /dev/null +++ b/backend/plugins/cursor/models/ai_code_commit.go @@ -0,0 +1,55 @@ +/* +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/incubator-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"` + CreatedAt *time.Time `json:"createdAt"` + + common.NoPKModel +} + +func (CursorAiCodeCommit) TableName() string { + return "_tool_cursor_ai_code_commits" +} diff --git a/backend/plugins/cursor/models/connection.go b/backend/plugins/cursor/models/connection.go index 21e19a4986b..f3f6892ba14 100644 --- a/backend/plugins/cursor/models/connection.go +++ b/backend/plugins/cursor/models/connection.go @@ -33,11 +33,21 @@ 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)"` } // SetupAuthentication uses HTTP Basic auth with the API key as username and an empty password. @@ -87,6 +97,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/migrationscripts/20260826_add_key_tier.go b/backend/plugins/cursor/models/migrationscripts/20260826_add_key_tier.go new file mode 100644 index 00000000000..7ca063a8ba1 --- /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/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" + "github.com/apache/incubator-devlake/helpers/migrationhelper" + "github.com/apache/incubator-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/register.go b/backend/plugins/cursor/models/migrationscripts/register.go index be0db9c2c29..6c0cf26780c 100644 --- a/backend/plugins/cursor/models/migrationscripts/register.go +++ b/backend/plugins/cursor/models/migrationscripts/register.go @@ -26,5 +26,6 @@ func All() []plugin.MigrationScript { new(changeCursorRequestsCostsToFloat), new(addCursorDailyUsage), new(addCursorDailyUsageLineFields), + new(addCursorKeyTier), } } diff --git a/backend/plugins/cursor/models/models.go b/backend/plugins/cursor/models/models.go index 4edf5f7c60b..3969a312d49 100644 --- a/backend/plugins/cursor/models/models.go +++ b/backend/plugins/cursor/models/models.go @@ -29,5 +29,7 @@ func GetTablesInfo() []dal.Tabler { &CursorUserSpend{}, &CursorMember{}, &CursorDailyUsage{}, + &CursorAiCodeCommit{}, + &CursorAiCodeChange{}, } } diff --git a/backend/plugins/cursor/service/connection_test.go b/backend/plugins/cursor/service/connection_test.go index c520351f1e5..85e69cd6703 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/incubator-devlake/plugins/cursor/models" "github.com/stretchr/testify/require" ) @@ -57,3 +58,93 @@ 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}) + require.Equal(t, models.KeyTierEnterprise, conn.KeyTier) + + ApplyTestResultToConnection(conn, nil) + require.Equal(t, models.KeyTierEnterprise, conn.KeyTier) + + ApplyTestResultToConnection(conn, &TestConnectionResult{}) + require.Equal(t, models.KeyTierEnterprise, conn.KeyTier) +} + +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..36c55d8e8f0 100644 --- a/backend/plugins/cursor/service/connection_test_helper.go +++ b/backend/plugins/cursor/service/connection_test_helper.go @@ -38,20 +38,22 @@ 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"` } // 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 +80,7 @@ func TestConnection(ctx stdctx.Context, br corectx.BasicRes, connection *models. return &TestConnectionResult{ Success: false, Message: userKeyErr.Error(), + KeyTier: models.KeyTierPersonal, }, nil } @@ -105,25 +108,58 @@ 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 } - hasEnterpriseAnalytics := probeEnterpriseAnalytics(apiClient) + permissions.Analytics = probeEndpoint(apiClient, "analytics/team/dau?startDate=7d&endDate=today") + permissions.AiCodeTracking = probeEndpoint(apiClient, "analytics/ai-code/commits?page=1&pageSize=1") + + keyTier := models.KeyTierTeam + if permissions.Analytics || permissions.AiCodeTracking { + keyTier = models.KeyTierEnterprise + } + + 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." + } 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 key tier from a test result onto the connection. +func ApplyTestResultToConnection(connection *models.CursorConnection, result *TestConnectionResult) { + if connection == nil || result == nil || result.KeyTier == "" { + return + } + connection.KeyTier = result.KeyTier +} + +// 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 +270,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..dee1e2a3480 --- /dev/null +++ b/backend/plugins/cursor/tasks/ai_code_changes_collector.go @@ -0,0 +1,116 @@ +/* +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/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-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 + } + var pagination struct { + TotalPages int `json:"totalPages"` + } + if jsonErr := json.Unmarshal(body, &pagination); jsonErr != nil { + return 0, errors.Default.Wrap(errors.Convert(jsonErr), "failed to parse ai-code/changes pagination") + } + if pagination.TotalPages <= 0 { + return 1, nil + } + return pagination.TotalPages, nil + }, + ResponseParser: parseAiCodeChangesResponse, + }) + if err != nil { + return err + } + + logUsageCollectionWindow(taskCtx.GetLogger(), "analytics/ai-code/changes", collector.GetSince(), collector.IsIncremental()) + return collector.Execute() +} + +func parseAiCodeChangesResponse(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 ai-code/changes response") + } + return response.Data, nil +} 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..5c8b02f3b27 --- /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/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-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.CreatedAt = ts + } + return []any{change}, nil + }, + }) + if err != nil { + return err + } + return extractor.Execute() +} 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..d45f58a1c57 --- /dev/null +++ b/backend/plugins/cursor/tasks/ai_code_commits_collector.go @@ -0,0 +1,158 @@ +/* +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" + "time" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-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 + } + var pagination struct { + TotalPages int `json:"totalPages"` + } + if jsonErr := json.Unmarshal(body, &pagination); jsonErr != nil { + return 0, errors.Default.Wrap(errors.Convert(jsonErr), "failed to parse ai-code/commits pagination") + } + if pagination.TotalPages <= 0 { + return 1, nil + } + return pagination.TotalPages, nil + }, + ResponseParser: parseAiCodeCommitsResponse, + }) + if err != nil { + return err + } + + logUsageCollectionWindow(taskCtx.GetLogger(), "analytics/ai-code/commits", collector.GetSince(), collector.IsIncremental()) + return collector.Execute() +} + +func parseAiCodeCommitsResponse(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 ai-code/commits response") + } + return response.Data, nil +} + +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..69ccb84ce9a --- /dev/null +++ b/backend/plugins/cursor/tasks/ai_code_commits_extractor.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 tasks + +import ( + "strings" + "time" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-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"` +} + +// 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, + } + if ts := parseOptionalISOTime(record.CommitTs); ts != nil { + commit.CommitTs = ts + } + if ts := parseOptionalISOTime(record.CreatedAt); ts != nil { + commit.CreatedAt = 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"} { + 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..da9c2c32314 --- /dev/null +++ b/backend/plugins/cursor/tasks/ai_code_test.go @@ -0,0 +1,98 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "testing" + "time" +) + +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: "date only", input: "2026-08-20", want: "2026-08-20T00:00:00Z"}, + {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]) + } +} diff --git a/backend/plugins/cursor/tasks/register.go b/backend/plugins/cursor/tasks/register.go index f802f0c9054..2be1611120a 100644 --- a/backend/plugins/cursor/tasks/register.go +++ b/backend/plugins/cursor/tasks/register.go @@ -30,5 +30,9 @@ func GetSubTaskMetas() []plugin.SubTaskMeta { ExtractUserSpendMeta, CollectDailyUsageMeta, ExtractDailyUsageMeta, + CollectAiCodeCommitsMeta, + ExtractAiCodeCommitsMeta, + CollectAiCodeChangesMeta, + ExtractAiCodeChangesMeta, } } diff --git a/backend/plugins/cursor/tasks/subtasks.go b/backend/plugins/cursor/tasks/subtasks.go index a56461910c0..da53bc8c018 100644 --- a/backend/plugins/cursor/tasks/subtasks.go +++ b/backend/plugins/cursor/tasks/subtasks.go @@ -86,3 +86,37 @@ 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 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}, +} 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)}