From dd7a1ed04f8345b99155778ae1f2e8accc0fee51 Mon Sep 17 00:00:00 2001 From: warren Date: Thu, 27 Aug 2026 10:24:46 +0800 Subject: [PATCH 1/4] feat(kiro): add S3 collection and plugin APIs Signed-off-by: warren --- backend/Dockerfile.local | 6 + backend/plugins/kiro/api/blueprint_v200.go | 85 +++++ backend/plugins/kiro/api/connection.go | 163 +++++++++ backend/plugins/kiro/api/init.go | 66 ++++ backend/plugins/kiro/api/remote_api.go | 338 ++++++++++++++++++ backend/plugins/kiro/api/s3_slice_api.go | 117 ++++++ backend/plugins/kiro/api/test_connection.go | 204 +++++++++++ backend/plugins/kiro/impl/impl.go | 208 +++++++++++ backend/plugins/kiro/impl/table_info_test.go | 37 ++ backend/plugins/kiro/kiro.go | 55 +++ backend/plugins/kiro/tasks/discovery.go | 158 ++++++++ backend/plugins/kiro/tasks/discovery_test.go | 211 +++++++++++ backend/plugins/kiro/tasks/extractor.go | 204 +++++++++++ backend/plugins/kiro/tasks/extractor_test.go | 184 ++++++++++ backend/plugins/kiro/tasks/identity_client.go | 93 +++++ backend/plugins/kiro/tasks/log_extractor.go | 84 +++++ backend/plugins/kiro/tasks/log_parser.go | 256 +++++++++++++ backend/plugins/kiro/tasks/log_parser_test.go | 319 +++++++++++++++++ backend/plugins/kiro/tasks/s3_client.go | 217 +++++++++++ backend/plugins/kiro/tasks/s3_client_test.go | 237 ++++++++++++ .../plugins/kiro/tasks/s3_file_collector.go | 171 +++++++++ .../kiro/tasks/s3_file_collector_test.go | 215 +++++++++++ backend/plugins/kiro/tasks/task_data.go | 105 ++++++ .../logs/chat_01_empty_prompt.json.gz | Bin 0 -> 274 bytes .../testdata/logs/chat_02_with_prompt.json.gz | Bin 0 -> 403 bytes .../testdata/logs/chat_03_two_records.json.gz | Bin 0 -> 315 bytes .../testdata/logs/chat_04_no_model_id.json.gz | Bin 0 -> 368 bytes .../logs/completion_01_non_empty.json.gz | Bin 0 -> 956 bytes .../testdata/logs/completion_02_empty.json.gz | Bin 0 -> 909 bytes .../kiro/tasks/user_report_extractor.go | 63 ++++ .../schema_e2e/migration_schema_test.go | 2 + backend/plugins/table_info_test.go | 4 +- 32 files changed, 3800 insertions(+), 2 deletions(-) create mode 100644 backend/plugins/kiro/api/blueprint_v200.go create mode 100644 backend/plugins/kiro/api/connection.go create mode 100644 backend/plugins/kiro/api/init.go create mode 100644 backend/plugins/kiro/api/remote_api.go create mode 100644 backend/plugins/kiro/api/s3_slice_api.go create mode 100644 backend/plugins/kiro/api/test_connection.go create mode 100644 backend/plugins/kiro/impl/impl.go create mode 100644 backend/plugins/kiro/impl/table_info_test.go create mode 100644 backend/plugins/kiro/kiro.go create mode 100644 backend/plugins/kiro/tasks/discovery.go create mode 100644 backend/plugins/kiro/tasks/discovery_test.go create mode 100644 backend/plugins/kiro/tasks/extractor.go create mode 100644 backend/plugins/kiro/tasks/extractor_test.go create mode 100644 backend/plugins/kiro/tasks/identity_client.go create mode 100644 backend/plugins/kiro/tasks/log_extractor.go create mode 100644 backend/plugins/kiro/tasks/log_parser.go create mode 100644 backend/plugins/kiro/tasks/log_parser_test.go create mode 100644 backend/plugins/kiro/tasks/s3_client.go create mode 100644 backend/plugins/kiro/tasks/s3_client_test.go create mode 100644 backend/plugins/kiro/tasks/s3_file_collector.go create mode 100644 backend/plugins/kiro/tasks/s3_file_collector_test.go create mode 100644 backend/plugins/kiro/tasks/task_data.go create mode 100644 backend/plugins/kiro/tasks/testdata/logs/chat_01_empty_prompt.json.gz create mode 100644 backend/plugins/kiro/tasks/testdata/logs/chat_02_with_prompt.json.gz create mode 100644 backend/plugins/kiro/tasks/testdata/logs/chat_03_two_records.json.gz create mode 100644 backend/plugins/kiro/tasks/testdata/logs/chat_04_no_model_id.json.gz create mode 100644 backend/plugins/kiro/tasks/testdata/logs/completion_01_non_empty.json.gz create mode 100644 backend/plugins/kiro/tasks/testdata/logs/completion_02_empty.json.gz create mode 100644 backend/plugins/kiro/tasks/user_report_extractor.go diff --git a/backend/Dockerfile.local b/backend/Dockerfile.local index 2c8b7ef1dcb..d4fab0a1480 100644 --- a/backend/Dockerfile.local +++ b/backend/Dockerfile.local @@ -73,6 +73,12 @@ RUN apt-get update && apt-get install -y \ libssh2-1 \ libssl3 \ ca-certificates \ + # gitextractor shells out to the git CLI unless + # USE_GO_GIT_IN_GIT_EXTRACTOR is set, so the binary must be present or every + # clone fails with "git: executable file not found in $PATH". + # debian:bookworm-slim does not ship it; the official image inherits it from + # its python base. + git \ && rm -rf /var/lib/apt/lists/* # Copy libgit2 diff --git a/backend/plugins/kiro/api/blueprint_v200.go b/backend/plugins/kiro/api/blueprint_v200.go new file mode 100644 index 00000000000..ceea9cbaee5 --- /dev/null +++ b/backend/plugins/kiro/api/blueprint_v200.go @@ -0,0 +1,85 @@ +/* +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 api + +import ( + "github.com/apache/incubator-devlake/core/errors" + coreModels "github.com/apache/incubator-devlake/core/models" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/helpers/srvhelper" + "github.com/apache/incubator-devlake/plugins/kiro/models" + "github.com/apache/incubator-devlake/plugins/kiro/tasks" +) + +func MakeDataSourcePipelinePlanV200( + subtaskMetas []plugin.SubTaskMeta, + connectionId uint64, + bpScopes []*coreModels.BlueprintScope, +) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) { + connection, err := dsHelper.ConnSrv.FindByPk(connectionId) + if err != nil { + return nil, nil, err + } + scopeDetails, err := dsHelper.ScopeSrv.MapScopeDetails(connectionId, bpScopes) + if err != nil { + return nil, nil, err + } + + plan, err := makeDataSourcePipelinePlanV200(subtaskMetas, scopeDetails, connection) + if err != nil { + return nil, nil, err + } + + // No domain layer scopes: this plugin writes only to _tool_kiro_* tables. + // Cross-tool AI modelling in the domain layer is separate work. + return plan, []plugin.Scope{}, nil +} + +func makeDataSourcePipelinePlanV200( + subtaskMetas []plugin.SubTaskMeta, + scopeDetails []*srvhelper.ScopeDetail[models.KiroS3Slice, srvhelper.NoScopeConfig], + connection *models.KiroConnection, +) (coreModels.PipelinePlan, errors.Error) { + plan := make(coreModels.PipelinePlan, len(scopeDetails)) + for i, scopeDetail := range scopeDetails { + slice := scopeDetail.Scope + + op := &tasks.KiroOptions{ + ConnectionId: slice.ConnectionId, + ScopeId: slice.Id, + AccountId: slice.AccountId, + Year: slice.Year, + Month: slice.Month, + } + + // An empty entity list enables every subtask; the three streams are + // always collected together because they describe the same activity. + task, err := helper.MakePipelinePlanTask("kiro", subtaskMetas, []string{}, op) + if err != nil { + return nil, err + } + + stage := plan[i] + if stage == nil { + stage = coreModels.PipelineStage{} + } + plan[i] = append(stage, task) + } + return plan, nil +} diff --git a/backend/plugins/kiro/api/connection.go b/backend/plugins/kiro/api/connection.go new file mode 100644 index 00000000000..075274b1d56 --- /dev/null +++ b/backend/plugins/kiro/api/connection.go @@ -0,0 +1,163 @@ +/* +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 api + +import ( + "net/http" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/kiro/models" +) + +// PostConnections creates a new connection. +// @Summary create kiro connection +// @Description Create kiro connection +// @Tags plugins/kiro +// @Param body body models.KiroConnection true "json body" +// @Success 200 {object} models.KiroConnection +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/kiro/connections [POST] +func PostConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.KiroConnection{} + // Wrapped as BadInput so a struct-tag validation failure reports 400 rather + // than 500 - the difference between "fix your input" and "the server broke". + if err := api.Decode(input.Body, connection, vld); err != nil { + return nil, errors.BadInput.Wrap(err, "invalid connection payload") + } + if err := validateConnection(&connection.KiroConn); err != nil { + return nil, errors.BadInput.Wrap(err, "connection validation failed") + } + if err := connectionHelper.Create(connection, input); err != nil { + return nil, err + } + return &plugin.ApiResourceOutput{Body: connection.Sanitize(), Status: http.StatusOK}, nil +} + +// PatchConnection updates an existing connection. +// @Summary patch kiro connection +// @Description Patch kiro connection +// @Tags plugins/kiro +// @Param id path int true "connection ID" +// @Param body body models.KiroConnection true "json body" +// @Success 200 {object} models.KiroConnection +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/kiro/connections/{id} [PATCH] +func PatchConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.KiroConnection{} + if err := connectionHelper.First(connection, input.Params); err != nil { + return nil, err + } + if err := (&models.KiroConnection{}).MergeFromRequest(connection, input.Body); err != nil { + return nil, errors.Convert(err) + } + if err := validateConnection(&connection.KiroConn); err != nil { + return nil, errors.BadInput.Wrap(err, "connection validation failed") + } + if err := connectionHelper.SaveWithCreateOrUpdate(connection); err != nil { + return nil, err + } + return &plugin.ApiResourceOutput{Body: connection.Sanitize(), Status: http.StatusOK}, nil +} + +// DeleteConnection removes a connection. +// @Summary delete a kiro connection +// @Description Delete a kiro connection +// @Tags plugins/kiro +// @Param id path int true "connection ID" +// @Success 200 {object} models.KiroConnection +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 409 {object} srvhelper.DsRefs "References exist to this connection" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/kiro/connections/{id} [DELETE] +func DeleteConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + conn := &models.KiroConnection{} + output, err := connectionHelper.Delete(conn, input) + if err != nil { + return output, err + } + output.Body = conn.Sanitize() + return output, nil +} + +// ListConnections lists all connections. +// @Summary get all kiro connections +// @Description Get all kiro connections +// @Tags plugins/kiro +// @Success 200 {object} []models.KiroConnection +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/kiro/connections [GET] +func ListConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + var connections []models.KiroConnection + if err := connectionHelper.List(&connections); err != nil { + return nil, err + } + for i := range connections { + connections[i] = connections[i].Sanitize() + } + return &plugin.ApiResourceOutput{Body: connections}, nil +} + +// GetConnection returns one connection. +// @Summary get kiro connection detail +// @Description Get kiro connection detail +// @Tags plugins/kiro +// @Param id path int true "connection ID" +// @Success 200 {object} models.KiroConnection +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/kiro/connections/{id} [GET] +func GetConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.KiroConnection{} + err := connectionHelper.First(connection, input.Params) + if err != nil { + return nil, err + } + return &plugin.ApiResourceOutput{Body: connection.Sanitize()}, nil +} + +// validateConnection checks the fields collection cannot proceed without. +// +// Identity Store fields are deliberately not required: they only resolve display +// names, and identity for joining to git history comes from the report's +// User_Email column. Requiring them would block a working setup. +func validateConnection(conn *models.KiroConn) error { + if conn.AccessKeyId == "" { + return errors.BadInput.New("AccessKeyId is required") + } + if conn.SecretAccessKey == "" { + return errors.BadInput.New("SecretAccessKey is required") + } + if conn.Region == "" { + return errors.BadInput.New("Region is required") + } + if conn.Bucket == "" { + return errors.BadInput.New("Bucket is required") + } + // A partial Identity Store configuration is a mistake worth reporting: it + // silently yields no display names, which looks like a data problem rather + // than a configuration one. + if (conn.IdentityStoreId == "") != (conn.IdentityStoreRegion == "") { + return errors.BadInput.New("IdentityStoreId and IdentityStoreRegion must be set together") + } + return nil +} diff --git a/backend/plugins/kiro/api/init.go b/backend/plugins/kiro/api/init.go new file mode 100644 index 00000000000..e4b614a743d --- /dev/null +++ b/backend/plugins/kiro/api/init.go @@ -0,0 +1,66 @@ +/* +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 api + +import ( + "github.com/go-playground/validator/v10" + + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/helpers/srvhelper" + "github.com/apache/incubator-devlake/plugins/kiro/models" +) + +var ( + vld *validator.Validate + connectionHelper *api.ConnectionApiHelper + basicRes context.BasicRes + // Scope config is NoScopeConfig: the report CSV's meaning is fixed by AWS + // and uniform across an organization, so there is nothing per-scope to + // configure. + dsHelper *api.DsHelper[models.KiroConnection, models.KiroS3Slice, srvhelper.NoScopeConfig] +) + +func Init(br context.BasicRes, p plugin.PluginMeta) { + basicRes = br + vld = validator.New() + connectionHelper = api.NewConnectionHelper( + basicRes, + vld, + p.Name(), + ) + + dsHelper = api.NewDataSourceHelper[ + models.KiroConnection, models.KiroS3Slice, srvhelper.NoScopeConfig, + ]( + basicRes, + p.Name(), + // Searchable scope fields. + []string{"accountId", "name"}, + func(c models.KiroConnection) models.KiroConnection { return c.Sanitize() }, + func(s models.KiroS3Slice) models.KiroS3Slice { return s.Sanitize() }, + nil, + ) + + // Scope browsing and search are implemented directly in remote_api.go rather + // than through the shared DsRemoteApiScopeList/Search helpers. Those build an + // HTTP client from the connection first, and that constructor runs a DNS + // check on the endpoint - a bucket name is not a hostname, so it fails. The + // helpers assume an HTTP data source; this one is S3. +} diff --git a/backend/plugins/kiro/api/remote_api.go b/backend/plugins/kiro/api/remote_api.go new file mode 100644 index 00000000000..1be7635558f --- /dev/null +++ b/backend/plugins/kiro/api/remote_api.go @@ -0,0 +1,338 @@ +/* +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 api + +import ( + "fmt" + "strconv" + "strings" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + dsmodels "github.com/apache/incubator-devlake/helpers/pluginhelper/api/models" + "github.com/apache/incubator-devlake/plugins/kiro/models" + "github.com/apache/incubator-devlake/plugins/kiro/tasks" +) + +// listKiroRemoteScopes browses the export layout as a tree. +// +// Three levels, mirroring Kiro's own S3 partitioning: +// +// (root) -> one group per AWS account with exported data +// {account} -> one group per year, plus a whole-year scope +// {account}/{y} -> one selectable scope per month +// +// Everything comes from S3 rather than user input. That is the point: a +// hand-typed prefix cannot be validated from the outcome, because a typo and a +// month with no data both produce a successful run that collects nothing. +func listKiroRemoteScopes(connection *models.KiroConnection, groupId string) ( + children []dsmodels.DsRemoteApiScopeListEntry[models.KiroS3Slice], + err errors.Error, +) { + if connection == nil { + return nil, errors.BadInput.New("connection is required") + } + + discovery, err := tasks.NewDiscovery(connection) + if err != nil { + return nil, err + } + + accountId, year, err := parseGroupId(groupId) + if err != nil { + return nil, err + } + + switch { + case accountId == "": + return listAccountGroups(discovery) + case year == 0: + return listYearGroups(discovery, accountId) + default: + return listMonthScopes(discovery, accountId, year) + } +} + +// listAccountGroups is the tree root: the accounts that actually have exports. +func listAccountGroups(discovery *tasks.Discovery) ( + []dsmodels.DsRemoteApiScopeListEntry[models.KiroS3Slice], errors.Error, +) { + accounts, err := discovery.ListAccounts() + if err != nil { + return nil, err + } + + entries := make([]dsmodels.DsRemoteApiScopeListEntry[models.KiroS3Slice], 0, len(accounts)) + for _, accountId := range accounts { + entries = append(entries, dsmodels.DsRemoteApiScopeListEntry[models.KiroS3Slice]{ + Type: api.RAS_ENTRY_TYPE_GROUP, + Id: accountId, + Name: accountId, + FullName: accountId, + }) + } + return entries, nil +} + +// listYearGroups lists the years under an account. +// +// Each year is offered both as a group to expand and as a directly selectable +// scope, because a nil month means "collect the whole year" - which is how a +// year-long backfill is expressed without creating twelve scopes by hand. +func listYearGroups(discovery *tasks.Discovery, accountId string) ( + []dsmodels.DsRemoteApiScopeListEntry[models.KiroS3Slice], errors.Error, +) { + years, err := discovery.ListYears(accountId) + if err != nil { + return nil, err + } + + entries := make([]dsmodels.DsRemoteApiScopeListEntry[models.KiroS3Slice], 0, len(years)*2) + for _, year := range years { + groupId := fmt.Sprintf("%s/%04d", accountId, year) + parent := accountId + + entries = append(entries, dsmodels.DsRemoteApiScopeListEntry[models.KiroS3Slice]{ + Type: api.RAS_ENTRY_TYPE_GROUP, + ParentId: &parent, + Id: groupId, + Name: fmt.Sprintf("%04d", year), + FullName: groupId, + }) + + wholeYear := &models.KiroS3Slice{AccountId: accountId, Year: year} + *wholeYear = wholeYear.Sanitize() + entries = append(entries, dsmodels.DsRemoteApiScopeListEntry[models.KiroS3Slice]{ + Type: api.RAS_ENTRY_TYPE_SCOPE, + ParentId: &parent, + Id: wholeYear.Id, + Name: fmt.Sprintf("%04d (whole year)", year), + FullName: wholeYear.ScopeName(), + Data: wholeYear, + }) + } + return entries, nil +} + +// listMonthScopes lists the months that hold data for an account and year. +func listMonthScopes(discovery *tasks.Discovery, accountId string, year int) ( + []dsmodels.DsRemoteApiScopeListEntry[models.KiroS3Slice], errors.Error, +) { + months, err := discovery.ListMonths(accountId, year) + if err != nil { + return nil, err + } + + parent := fmt.Sprintf("%s/%04d", accountId, year) + entries := make([]dsmodels.DsRemoteApiScopeListEntry[models.KiroS3Slice], 0, len(months)) + for _, month := range months { + m := month + slice := &models.KiroS3Slice{AccountId: accountId, Year: year, Month: &m} + *slice = slice.Sanitize() + + entries = append(entries, dsmodels.DsRemoteApiScopeListEntry[models.KiroS3Slice]{ + Type: api.RAS_ENTRY_TYPE_SCOPE, + ParentId: &parent, + Id: slice.Id, + Name: fmt.Sprintf("%04d-%02d", year, month), + FullName: slice.ScopeName(), + Data: slice, + }) + } + return entries, nil +} + +// searchKiroRemoteScopes filters the discovered months by substring. +// +// Matching is against "{account} {year}-{month}", so "2026-07" or an account +// number both work. The search space is one listing per year, small enough to +// scan without an index. +func searchKiroRemoteScopes( + connection *models.KiroConnection, + query string, + page int, + pageSize int, +) ( + children []dsmodels.DsRemoteApiScopeListEntry[models.KiroS3Slice], + err errors.Error, +) { + empty := []dsmodels.DsRemoteApiScopeListEntry[models.KiroS3Slice]{} + if connection == nil { + return empty, nil + } + query = strings.ToLower(strings.TrimSpace(query)) + if query == "" { + return empty, nil + } + + discovery, err := tasks.NewDiscovery(connection) + if err != nil { + return nil, err + } + accounts, err := discovery.ListAccounts() + if err != nil { + return nil, err + } + + matches := make([]dsmodels.DsRemoteApiScopeListEntry[models.KiroS3Slice], 0) + for _, accountId := range accounts { + years, yearErr := discovery.ListYears(accountId) + if yearErr != nil { + return nil, yearErr + } + for _, year := range years { + months, monthErr := discovery.ListMonths(accountId, year) + if monthErr != nil { + return nil, monthErr + } + for _, month := range months { + m := month + slice := &models.KiroS3Slice{AccountId: accountId, Year: year, Month: &m} + *slice = slice.Sanitize() + + if !strings.Contains(strings.ToLower(slice.ScopeName()), query) && + !strings.Contains(strings.ToLower(slice.Id), query) { + continue + } + matches = append(matches, dsmodels.DsRemoteApiScopeListEntry[models.KiroS3Slice]{ + Type: api.RAS_ENTRY_TYPE_SCOPE, + Id: slice.Id, + Name: slice.ScopeName(), + FullName: slice.ScopeName(), + Data: slice, + }) + } + } + } + + return paginate(matches, page, pageSize), nil +} + +// paginate applies the requested page window to an in-memory result set. +func paginate( + entries []dsmodels.DsRemoteApiScopeListEntry[models.KiroS3Slice], + page int, pageSize int, +) []dsmodels.DsRemoteApiScopeListEntry[models.KiroS3Slice] { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 50 + } + start := (page - 1) * pageSize + if start >= len(entries) { + return []dsmodels.DsRemoteApiScopeListEntry[models.KiroS3Slice]{} + } + end := start + pageSize + if end > len(entries) { + end = len(entries) + } + return entries[start:end] +} + +// parseGroupId splits a tree node id into its parts. +// +// "" is the root, "{account}" is an account node, "{account}/{year}" is a year +// node. +func parseGroupId(groupId string) (accountId string, year int, err errors.Error) { + trimmed := strings.Trim(strings.TrimSpace(groupId), "/") + if trimmed == "" { + return "", 0, nil + } + + parts := strings.Split(trimmed, "/") + switch len(parts) { + case 1: + return parts[0], 0, nil + case 2: + parsedYear, convErr := strconv.Atoi(parts[1]) + if convErr != nil { + return "", 0, errors.BadInput.New("invalid year in groupId: " + groupId) + } + return parts[0], parsedYear, nil + default: + return "", 0, errors.BadInput.New("unrecognized groupId: " + groupId) + } +} + +// RemoteScopes browses the Kiro export layout in S3. +// +// Implemented directly rather than through the shared scope-list helper. That +// helper builds an HTTP client from the connection first, and its constructor +// runs a DNS check on the endpoint - which fails here, because a bucket name is +// not a hostname. The helper is built for HTTP data sources; this one is S3. +// @Summary list available kiro scopes discovered from S3 +// @Description Browse accounts, years and months that actually have exported data +// @Tags plugins/kiro +// @Accept application/json +// @Param connectionId path int true "connection ID" +// @Param groupId query string false "account id, or account/year" +// @Success 200 {object} dsmodels.DsRemoteApiScopeList[models.KiroS3Slice] +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/kiro/connections/{connectionId}/remote-scopes [GET] +func RemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.KiroConnection{} + if err := connectionHelper.First(connection, input.Params); err != nil { + return nil, err + } + + children, err := listKiroRemoteScopes(connection, input.Query.Get("groupId")) + if err != nil { + return nil, err + } + return &plugin.ApiResourceOutput{ + Body: dsmodels.DsRemoteApiScopeList[models.KiroS3Slice]{Children: children}, + }, nil +} + +// SearchRemoteScopes finds discovered scopes by substring. +// +// Implemented directly rather than through the shared search helper: that +// helper's callback receives only an HTTP ApiClient, and discovery here needs +// the connection itself to build an S3 client. +// @Summary search kiro scopes discovered from S3 +// @Description Search the discovered months by account or year-month +// @Tags plugins/kiro +// @Accept application/json +// @Param connectionId path int true "connection ID" +// @Param search query string false "search" +// @Param page query int false "page number" +// @Param pageSize query int false "page size per page" +// @Success 200 {object} dsmodels.DsRemoteApiScopeList[models.KiroS3Slice] "the parentIds are always null" +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/kiro/connections/{connectionId}/search-remote-scopes [GET] +func SearchRemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.KiroConnection{} + if err := connectionHelper.First(connection, input.Params); err != nil { + return nil, err + } + + page, _ := strconv.Atoi(input.Query.Get("page")) + pageSize, _ := strconv.Atoi(input.Query.Get("pageSize")) + + children, err := searchKiroRemoteScopes(connection, input.Query.Get("search"), page, pageSize) + if err != nil { + return nil, err + } + return &plugin.ApiResourceOutput{ + Body: dsmodels.DsRemoteApiScopeList[models.KiroS3Slice]{Children: children}, + }, nil +} diff --git a/backend/plugins/kiro/api/s3_slice_api.go b/backend/plugins/kiro/api/s3_slice_api.go new file mode 100644 index 00000000000..f8a05fb185f --- /dev/null +++ b/backend/plugins/kiro/api/s3_slice_api.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 api + +import ( + "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/helpers/srvhelper" + "github.com/apache/incubator-devlake/plugins/kiro/models" +) + +type PutScopesReqBody = helper.PutScopesReqBody[models.KiroS3Slice] +type ScopeDetail = srvhelper.ScopeDetail[models.KiroS3Slice, srvhelper.NoScopeConfig] + +// PutScopes creates or updates Kiro collection scopes. +// @Summary create or update kiro scopes +// @Description Create or update kiro scopes, each covering one AWS account for one month +// @Tags plugins/kiro +// @Accept application/json +// @Param connectionId path int true "connection ID" +// @Param scope body PutScopesReqBody true "json" +// @Success 200 {object} []models.KiroS3Slice +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/kiro/connections/{connectionId}/scopes [PUT] +func PutScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.PutMultiple(input) +} + +// GetScopeList returns the scopes for a connection. +// @Summary get kiro scopes +// @Description get kiro scopes +// @Tags plugins/kiro +// @Param connectionId path int true "connection ID" +// @Param pageSize query int false "page size" +// @Param page query int false "page number" +// @Param blueprints query bool false "include blueprint references" +// @Success 200 {object} []ScopeDetail +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/kiro/connections/{connectionId}/scopes [GET] +func GetScopeList(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.GetPage(input) +} + +// GetScope returns a single scope. +// @Summary get one kiro scope +// @Description get one kiro scope +// @Tags plugins/kiro +// @Param connectionId path int true "connection ID" +// @Param scopeId path string true "scope ID" +// @Success 200 {object} ScopeDetail +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/kiro/connections/{connectionId}/scopes/{scopeId} [GET] +func GetScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.GetScopeDetail(input) +} + +// PatchScope updates a scope. +// @Summary patch a kiro scope +// @Description patch a kiro scope +// @Tags plugins/kiro +// @Param connectionId path int true "connection ID" +// @Param scopeId path string true "scope ID" +// @Param scope body models.KiroS3Slice true "json" +// @Success 200 {object} models.KiroS3Slice +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/kiro/connections/{connectionId}/scopes/{scopeId} [PATCH] +func PatchScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.Patch(input) +} + +// DeleteScope removes a scope and optionally its collected data. +// @Summary delete a kiro scope +// @Description delete a kiro scope +// @Tags plugins/kiro +// @Param connectionId path int true "connection ID" +// @Param scopeId path string true "scope ID" +// @Success 200 {object} ScopeDetail +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/kiro/connections/{connectionId}/scopes/{scopeId} [DELETE] +func DeleteScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.Delete(input) +} + +// GetScopeLatestSyncState reports the most recent sync for a scope. +// @Summary get the latest sync state of a kiro scope +// @Description get the latest sync state of a kiro scope +// @Tags plugins/kiro +// @Param connectionId path int true "connection ID" +// @Param scopeId path string true "scope ID" +// @Success 200 {object} []models.LatestSyncState +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/kiro/connections/{connectionId}/scopes/{scopeId}/latest-sync-state [GET] +func GetScopeLatestSyncState(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.GetScopeLatestSyncState(input) +} diff --git a/backend/plugins/kiro/api/test_connection.go b/backend/plugins/kiro/api/test_connection.go new file mode 100644 index 00000000000..8df055e582f --- /dev/null +++ b/backend/plugins/kiro/api/test_connection.go @@ -0,0 +1,204 @@ +/* +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 api + +import ( + "fmt" + "net/http" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/s3" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/kiro/models" + "github.com/apache/incubator-devlake/plugins/kiro/tasks" +) + +// TestConnection validates a connection that has not been saved yet. +// @Summary test kiro connection +// @Description Test kiro connection +// @Tags plugins/kiro +// @Param body body models.KiroConn true "json body" +// @Success 200 {object} ConnectionReport +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/kiro/test [POST] +func TestConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + var connection models.KiroConnection + // Wrapped as BadInput: a struct-tag validation failure is the caller's + // problem, and an unwrapped Decode error surfaces as HTTP 500 - which sends + // the user looking at server logs instead of at their own form. + if err := api.Decode(input.Body, &connection, vld); err != nil { + return nil, errors.BadInput.Wrap(err, "invalid connection payload") + } + if err := validateConnection(&connection.KiroConn); err != nil { + return nil, errors.BadInput.Wrap(err, "connection validation failed") + } + if err := testConnection(&connection); err != nil { + return nil, err + } + return &plugin.ApiResourceOutput{ + Body: buildConnectionReport(&connection), + Status: http.StatusOK, + }, nil +} + +// TestExistingConnection validates a saved connection, optionally with overrides +// from the request body. +// @Summary test existing kiro connection +// @Description Test existing kiro connection +// @Tags plugins/kiro +// @Param id path int true "connection ID" +// @Success 200 {object} ConnectionReport +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/kiro/connections/{id}/test [POST] +func TestExistingConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.KiroConnection{} + if err := connectionHelper.First(connection, input.Params); err != nil { + return nil, errors.BadInput.Wrap(err, "find connection from db") + } + if err := api.DecodeMapStruct(input.Body, connection, false); err != nil { + return nil, errors.BadInput.Wrap(err, "invalid connection payload") + } + if err := testConnection(connection); err != nil { + return nil, err + } + return &plugin.ApiResourceOutput{ + Body: buildConnectionReport(connection), + Status: http.StatusOK, + }, nil +} + +// ConnectionReport describes what the connection can actually see. +// +// A bare success/failure verdict is not enough to tell whether a configuration +// is right, because a wrong prefix and a genuinely empty period produce the same +// outcome: collection runs, finds nothing, and reports success. Returning the +// discovered accounts and per-stream object counts makes the difference visible +// before any scope is created. +type ConnectionReport struct { + ReportBucket string `json:"reportBucket"` + PromptLogBucket string `json:"promptLogBucket"` + // Accounts are the AWS account ids found under the report prefix. An empty + // list is the clearest sign that the bucket or prefix is wrong. + Accounts []string `json:"accounts"` + // Streams reports object counts for the most recent discovered period. + Streams []tasks.StreamCount `json:"streams"` + // Hint explains an empty result in plain terms. + Hint string `json:"hint,omitempty"` +} + +// connectionReportCountLimit caps counting so the check stays fast on a bucket +// holding hundreds of thousands of objects. The exact number does not matter for +// verifying a path - only whether it is zero. +const connectionReportCountLimit = 500 + +// buildConnectionReport probes the layout and summarizes what was found. +// +// Errors are folded into the report rather than returned: the connection itself +// is already known to work at this point, and a discovery failure is more useful +// shown as an empty result with a hint than as a failed request. +func buildConnectionReport(connection *models.KiroConnection) *ConnectionReport { + report := &ConnectionReport{ + ReportBucket: connection.Bucket, + PromptLogBucket: connection.GetPromptLogBucket(), + Accounts: []string{}, + Streams: []tasks.StreamCount{}, + } + + discovery, err := tasks.NewDiscovery(connection) + if err != nil { + report.Hint = "could not initialise S3 discovery: " + err.Error() + return report + } + + accounts, err := discovery.ListAccounts() + if err != nil { + report.Hint = "could not list accounts under the report prefix: " + err.Error() + return report + } + report.Accounts = accounts + + if len(accounts) == 0 { + report.Hint = fmt.Sprintf( + "no account directories under s3://%s/%s/AWSLogs/ - check the bucket and report prefix", + connection.Bucket, connection.GetReportPrefix()) + return report + } + + // Probe the newest period that exists, since that is where data is most + // likely to be and therefore the most informative check. + accountId := accounts[len(accounts)-1] + years, err := discovery.ListYears(accountId) + if err != nil || len(years) == 0 { + report.Hint = fmt.Sprintf( + "account %s has no year directories - check the region in the connection", accountId) + return report + } + year := years[len(years)-1] + + var month *int + if months, monthErr := discovery.ListMonths(accountId, year); monthErr == nil && len(months) > 0 { + latest := months[len(months)-1] + month = &latest + } + + report.Streams = discovery.CountStreams(accountId, year, month, connectionReportCountLimit) + + period := fmt.Sprintf("%04d", year) + if month != nil { + period = fmt.Sprintf("%04d-%02d", year, *month) + } + report.Hint = fmt.Sprintf("counts are for account %s, period %s", accountId, period) + return report +} + +// testConnection issues a real request against every bucket in use. +// +// Constructing a client proves nothing - the AWS SDK builds one happily from +// invalid credentials, so a test that stops there reports success for a +// connection that cannot read anything. A single-key list is the cheapest call +// that actually exercises credentials and bucket permissions. +// +// Both buckets are checked when reports and logs are separated, since they may +// carry different KMS keys and IAM conditions; a connection that can read +// reports but not logs would otherwise pass and then silently collect nothing. +func testConnection(connection *models.KiroConnection) errors.Error { + clients, err := tasks.NewKiroS3Clients(connection) + if err != nil { + return err + } + + for _, bucket := range clients.Buckets() { + client := clients.Report + if bucket == clients.PromptLog.Bucket { + client = clients.PromptLog + } + if _, listErr := client.S3.ListObjectsV2(&s3.ListObjectsV2Input{ + Bucket: aws.String(bucket), + MaxKeys: aws.Int64(1), + }); listErr != nil { + return errors.BadInput.Wrap(listErr, "cannot access s3 bucket "+bucket) + } + } + + return nil +} diff --git a/backend/plugins/kiro/impl/impl.go b/backend/plugins/kiro/impl/impl.go new file mode 100644 index 00000000000..c6b19708aeb --- /dev/null +++ b/backend/plugins/kiro/impl/impl.go @@ -0,0 +1,208 @@ +/* +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 impl + +import ( + "fmt" + + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/dal" + "github.com/apache/incubator-devlake/core/errors" + coreModels "github.com/apache/incubator-devlake/core/models" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/kiro/api" + "github.com/apache/incubator-devlake/plugins/kiro/models" + "github.com/apache/incubator-devlake/plugins/kiro/models/migrationscripts" + "github.com/apache/incubator-devlake/plugins/kiro/tasks" +) + +var _ interface { + plugin.PluginMeta + plugin.PluginInit + plugin.PluginTask + plugin.PluginApi + plugin.PluginModel + plugin.PluginSource + plugin.PluginMigration + plugin.DataSourcePluginBlueprintV200 +} = (*Kiro)(nil) + +// Kiro collects Kiro enterprise usage exports from S3. +// +// This is a separate plugin rather than an evolution of the retired predecessor: +// the old format is frozen, and a plugin that keeps evolving should not depend +// on frozen code. No implementation code is shared, following the same split +// as bitbucket and bitbucket_server. +type Kiro struct{} + +func (p Kiro) Init(basicRes context.BasicRes) errors.Error { + api.Init(basicRes, p) + return nil +} + +func (p Kiro) Name() string { + return "kiro" +} + +func (p Kiro) Description() string { + return "collect Kiro usage reports and interaction logs from S3" +} + +func (p Kiro) RootPkgPath() string { + return "github.com/apache/incubator-devlake/plugins/kiro" +} + +// GetTablesInfo must list every model or plugins/table_info_test.go fails. +func (p Kiro) GetTablesInfo() []dal.Tabler { + return []dal.Tabler{ + &models.KiroConnection{}, + &models.KiroS3Slice{}, + &models.KiroS3FileMeta{}, + &models.KiroUserReport{}, + &models.KiroUserModelMessage{}, + &models.KiroChatLog{}, + &models.KiroCompletionLog{}, + } +} + +func (p Kiro) Connection() dal.Tabler { + return &models.KiroConnection{} +} + +func (p Kiro) Scope() plugin.ToolLayerScope { + return &models.KiroS3Slice{} +} + +// ScopeConfig returns nil: the export format is defined by AWS and uniform +// across an organization, so there is nothing per-scope to configure. +func (p Kiro) ScopeConfig() dal.Tabler { + return nil +} + +func (p Kiro) MigrationScripts() []plugin.MigrationScript { + return migrationscripts.All() +} + +// SubTaskMetas lists discovery first, then one extractor per stream. The +// extractors declare their dependency on discovery, so the split is safe and +// gives each stream its own progress reporting - useful when a scope holds tens +// of thousands of log objects and one needs to know which stream is slow. +func (p Kiro) SubTaskMetas() []plugin.SubTaskMeta { + return []plugin.SubTaskMeta{ + tasks.CollectKiroS3FilesMeta, + tasks.ExtractKiroUserReportMeta, + tasks.ExtractKiroChatLogMeta, + tasks.ExtractKiroCompletionLogMeta, + } +} + +func (p Kiro) PrepareTaskData(taskCtx plugin.TaskContext, options map[string]interface{}) (interface{}, errors.Error) { + var op tasks.KiroOptions + if err := helper.Decode(options, &op, nil); err != nil { + return nil, err + } + if op.ConnectionId == 0 { + return nil, errors.BadInput.New("connectionId is required") + } + if op.AccountId == "" { + return nil, errors.BadInput.New("accountId is required") + } + if op.Year <= 0 { + return nil, errors.BadInput.New("year is required") + } + + connectionHelper := helper.NewConnectionHelper(taskCtx, nil, p.Name()) + connection := &models.KiroConnection{} + if err := connectionHelper.FirstById(connection, op.ConnectionId); err != nil { + return nil, err + } + + s3Clients, err := tasks.NewKiroS3Clients(connection) + if err != nil { + return nil, err + } + + // Identity Store is optional and only supplies display names, so a failure + // here degrades presentation rather than collection. + identityClient, identityErr := tasks.NewKiroIdentityClient(connection) + if identityErr != nil { + taskCtx.GetLogger().Warn(identityErr, "identity store unavailable, proceeding without display names") + identityClient = nil + } + + timePath := fmt.Sprintf("%04d", op.Year) + if op.Month != nil { + timePath = fmt.Sprintf("%04d/%02d", op.Year, *op.Month) + } + + return &tasks.KiroTaskData{ + Options: &op, + Connection: connection, + S3Clients: s3Clients, + IdentityClient: identityClient, + Prefixes: tasks.BuildPrefixes(connection, op.AccountId, timePath), + }, nil +} + +func (p Kiro) MakeDataSourcePipelinePlanV200( + connectionId uint64, + scopes []*coreModels.BlueprintScope, +) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) { + return api.MakeDataSourcePipelinePlanV200(p.SubTaskMetas(), connectionId, scopes) +} + +func (p Kiro) ApiResources() map[string]map[string]plugin.ApiResourceHandler { + return map[string]map[string]plugin.ApiResourceHandler{ + "test": { + "POST": api.TestConnection, + }, + "connections": { + "POST": api.PostConnections, + "GET": api.ListConnections, + }, + "connections/:connectionId": { + "GET": api.GetConnection, + "PATCH": api.PatchConnection, + "DELETE": api.DeleteConnection, + }, + "connections/:connectionId/test": { + "POST": api.TestExistingConnection, + }, + // Scope discovery: lists the accounts, years and months that actually + // have exported data, so a scope is selected instead of hand-entered. + "connections/:connectionId/remote-scopes": { + "GET": api.RemoteScopes, + }, + "connections/:connectionId/search-remote-scopes": { + "GET": api.SearchRemoteScopes, + }, + "connections/:connectionId/scopes": { + "GET": api.GetScopeList, + "PUT": api.PutScopes, + }, + "connections/:connectionId/scopes/:scopeId": { + "GET": api.GetScope, + "PATCH": api.PatchScope, + "DELETE": api.DeleteScope, + }, + "connections/:connectionId/scopes/:scopeId/latest-sync-state": { + "GET": api.GetScopeLatestSyncState, + }, + } +} diff --git a/backend/plugins/kiro/impl/table_info_test.go b/backend/plugins/kiro/impl/table_info_test.go new file mode 100644 index 00000000000..cb195602d63 --- /dev/null +++ b/backend/plugins/kiro/impl/table_info_test.go @@ -0,0 +1,37 @@ +/* +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 impl + +import ( + "testing" + + "github.com/apache/incubator-devlake/helpers/unithelper" +) + +// The repo-wide plugins/table_info_test.go performs this same check for every +// plugin, but building that package requires gitextractor and therefore a +// specific libgit2. Running it here as well keeps the feedback local: a model +// added without registering it in GetTablesInfo fails immediately rather than +// only in CI. +func TestKiroTableInfo(t *testing.T) { + checker := unithelper.NewTableInfoChecker(unithelper.TableInfoCheckerConfig{}) + checker.FeedIn("../models", Kiro{}.GetTablesInfo) + if err := checker.Verify(); err != nil { + t.Error(err) + } +} diff --git a/backend/plugins/kiro/kiro.go b/backend/plugins/kiro/kiro.go new file mode 100644 index 00000000000..27c8dff3032 --- /dev/null +++ b/backend/plugins/kiro/kiro.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 main + +import ( + "github.com/spf13/cobra" + + "github.com/apache/incubator-devlake/core/runner" + "github.com/apache/incubator-devlake/plugins/kiro/impl" +) + +var PluginEntry impl.Kiro + +// standalone mode for debugging +func main() { + cmd := &cobra.Command{Use: "kiro"} + connectionId := cmd.Flags().Uint64P("connectionId", "c", 0, "kiro connection id") + accountId := cmd.Flags().StringP("accountId", "a", "", "AWS account id that Kiro exports for") + year := cmd.Flags().IntP("year", "y", 0, "year to collect") + month := cmd.Flags().IntP("month", "m", 0, "month to collect; omit to collect the whole year") + + _ = cmd.MarkFlagRequired("connectionId") + _ = cmd.MarkFlagRequired("accountId") + _ = cmd.MarkFlagRequired("year") + + cmd.Run = func(cmd *cobra.Command, args []string) { + options := map[string]interface{}{ + "connectionId": *connectionId, + "accountId": *accountId, + "year": *year, + } + // A zero month means the whole year, matching the scope model where a + // nil month widens collection. + if *month > 0 { + options["month"] = *month + } + runner.DirectRun(cmd, args, PluginEntry, options, "") + } + runner.RunCmd(cmd) +} diff --git a/backend/plugins/kiro/tasks/discovery.go b/backend/plugins/kiro/tasks/discovery.go new file mode 100644 index 00000000000..1bd6a134759 --- /dev/null +++ b/backend/plugins/kiro/tasks/discovery.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 ( + "fmt" + "strconv" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/plugins/kiro/models" +) + +// Discovery walks the report prefix to find what data actually exists. +// +// Kiro's S3 layout encodes every scope dimension as a path segment: +// +// {reportPrefix}/AWSLogs/{accountId}/KiroLogs/user_report/{region}/{year}/{month}/ +// +// so a scope never has to be typed by hand. That matters beyond convenience: a +// mistyped prefix produces exactly the same outcome as a month with no data - +// collection succeeds and finds nothing - so hand-entered paths cannot be +// verified from the result. +type Discovery struct { + clients *KiroS3Clients + connection *models.KiroConnection +} + +func NewDiscovery(connection *models.KiroConnection) (*Discovery, errors.Error) { + clients, err := NewKiroS3Clients(connection) + if err != nil { + return nil, err + } + return &Discovery{clients: clients, connection: connection}, nil +} + +// reportRoot is the prefix holding the per-account directories. +func (d *Discovery) reportRoot() string { + return fmt.Sprintf("%s/AWSLogs", d.connection.GetReportPrefix()) +} + +// accountReportPrefix is where one account's report months live. +func (d *Discovery) accountReportPrefix(accountId string) string { + return fmt.Sprintf("%s/AWSLogs/%s/KiroLogs/user_report/%s", + d.connection.GetReportPrefix(), accountId, d.connection.Region) +} + +// ListAccounts returns the AWS account ids that have exported data. +// +// Kiro requires a bucket per account holding subscriptions and does not support +// cross-account buckets, so in practice this is usually one entry - but reading +// it from S3 removes the chance of a typo in a 12-digit number. +func (d *Discovery) ListAccounts() ([]string, errors.Error) { + return d.clients.Report.ListSubPrefixes(d.reportRoot()) +} + +// ListYears returns the years with report data for an account. +func (d *Discovery) ListYears(accountId string) ([]int, errors.Error) { + names, err := d.clients.Report.ListSubPrefixes(d.accountReportPrefix(accountId)) + if err != nil { + return nil, err + } + return parseNumericSegments(names), nil +} + +// ListMonths returns the months with report data for an account and year. +func (d *Discovery) ListMonths(accountId string, year int) ([]int, errors.Error) { + prefix := fmt.Sprintf("%s/%04d", d.accountReportPrefix(accountId), year) + names, err := d.clients.Report.ListSubPrefixes(prefix) + if err != nil { + return nil, err + } + return parseNumericSegments(names), nil +} + +// StreamCount is how many collectable objects one stream holds. +type StreamCount struct { + FileType string `json:"fileType"` + Bucket string `json:"bucket"` + Prefix string `json:"prefix"` + Count int `json:"count"` + // AtLeast is true when counting stopped at the cap, so Count is a floor. + AtLeast bool `json:"atLeast"` + // Error explains why a stream could not be counted, e.g. missing + // permission on the log bucket while the report bucket is readable. + Error string `json:"error,omitempty"` +} + +// CountStreams reports the object count for each of the three streams. +// +// This is the answer to "is my configuration right?". Reporting counts per +// stream distinguishes the three cases that otherwise look identical: a wrong +// prefix (zero everywhere), a genuinely dormant stream (zero for one type - +// inline completions stopped being produced under agentic usage), and a +// permissions gap on one bucket (an error for the log streams only). +// +// countLimit caps the work per stream; pass 0 to count everything. +func (d *Discovery) CountStreams(accountId string, year int, month *int, countLimit int) []StreamCount { + timePath := fmt.Sprintf("%04d", year) + if month != nil { + timePath = fmt.Sprintf("%04d/%02d", year, *month) + } + + specs := BuildPrefixes(d.connection, accountId, timePath) + results := make([]StreamCount, 0, len(specs)) + + for _, spec := range specs { + client := d.clients.ForFileType(spec.FileType) + result := StreamCount{ + FileType: spec.FileType, + Bucket: client.Bucket, + Prefix: spec.Prefix, + } + count, atLeast, err := client.CountObjects(spec.Prefix, countLimit) + if err != nil { + // Recorded rather than returned: one unreadable stream should still + // leave the others' counts visible, since that contrast is what + // identifies a per-bucket permission problem. + result.Error = err.Error() + } else { + result.Count = count + result.AtLeast = atLeast + } + results = append(results, result) + } + + return results +} + +// parseNumericSegments keeps only the segments that are numbers, in order. +// +// S3 prefixes are strings, and a non-numeric directory would otherwise surface +// as a year or month. +func parseNumericSegments(names []string) []int { + values := make([]int, 0, len(names)) + for _, name := range names { + value, err := strconv.Atoi(name) + if err != nil { + continue + } + values = append(values, value) + } + return values +} diff --git a/backend/plugins/kiro/tasks/discovery_test.go b/backend/plugins/kiro/tasks/discovery_test.go new file mode 100644 index 00000000000..785f79cbd58 --- /dev/null +++ b/backend/plugins/kiro/tasks/discovery_test.go @@ -0,0 +1,211 @@ +/* +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" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/incubator-devlake/plugins/kiro/models" +) + +// prefixMockS3 answers listings from a canned prefix tree, and records the +// requests so tests can assert that a delimiter was used. +type prefixMockS3 struct { + // commonPrefixes maps a queried prefix to the child prefixes returned. + commonPrefixes map[string][]string + // objects maps a queried prefix to the object keys beneath it. + objects map[string][]string + seenDelimiter []string + seenPrefix []string +} + +func (m *prefixMockS3) ListObjectsV2(input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) { + prefix := "" + if input.Prefix != nil { + prefix = *input.Prefix + } + m.seenPrefix = append(m.seenPrefix, prefix) + if input.Delimiter != nil { + m.seenDelimiter = append(m.seenDelimiter, *input.Delimiter) + } else { + m.seenDelimiter = append(m.seenDelimiter, "") + } + + out := &s3.ListObjectsV2Output{IsTruncated: aws.Bool(false)} + for _, child := range m.commonPrefixes[prefix] { + full := prefix + child + "/" + out.CommonPrefixes = append(out.CommonPrefixes, &s3.CommonPrefix{Prefix: aws.String(full)}) + } + for _, key := range m.objects[prefix] { + out.Contents = append(out.Contents, &s3.Object{Key: aws.String(prefix + key)}) + } + return out, nil +} + +func (m *prefixMockS3) GetObject(*s3.GetObjectInput) (*s3.GetObjectOutput, error) { + return nil, nil +} + +func discoveryFixture(svc S3API) *Discovery { + conn := &models.KiroConnection{KiroConn: models.KiroConn{ + Region: "us-east-1", + Bucket: "kiro-export-test", + ReportPrefix: "user-report", + PromptLogPrefix: "logging", + }} + client := &KiroS3Client{S3: svc, Bucket: conn.Bucket} + return &Discovery{ + clients: &KiroS3Clients{Report: client, PromptLog: client}, + connection: conn, + } +} + +// Kiro encodes every scope dimension as a path segment, which is what allows a +// scope to be selected rather than typed. This matters beyond convenience: a +// mistyped prefix and a month with no data produce the same outcome - a +// successful run that collects nothing - so a hand-entered path cannot be +// verified from the result. +func TestDiscovery_ListsLayoutFromS3(t *testing.T) { + // The real layout, as confirmed against the live bucket. + svc := &prefixMockS3{commonPrefixes: map[string][]string{ + "user-report/AWSLogs/": {"123456789012"}, + "user-report/AWSLogs/123456789012/KiroLogs/user_report/us-east-1/": {"2026"}, + "user-report/AWSLogs/123456789012/KiroLogs/user_report/us-east-1/2026/": {"02", "03", "07"}, + }} + discovery := discoveryFixture(svc) + + accounts, err := discovery.ListAccounts() + require.Nil(t, err) + assert.Equal(t, []string{"123456789012"}, accounts) + + years, err := discovery.ListYears("123456789012") + require.Nil(t, err) + assert.Equal(t, []int{2026}, years) + + months, err := discovery.ListMonths("123456789012", 2026) + require.Nil(t, err) + // Zero-padded segments must parse to plain integers, and only months that + // actually hold data are offered. + assert.Equal(t, []int{2, 3, 7}, months) + + // A delimiter is required: without it S3 returns every object under the + // prefix instead of just the segment names, which on a log prefix would be + // hundreds of thousands of keys. + for _, delimiter := range svc.seenDelimiter { + assert.Equal(t, "/", delimiter) + } +} + +func TestDiscovery_IgnoresNonNumericSegments(t *testing.T) { + svc := &prefixMockS3{commonPrefixes: map[string][]string{ + "user-report/AWSLogs/123456789012/KiroLogs/user_report/us-east-1/": {"2026", "unexpected", "2025"}, + }} + years, err := discoveryFixture(svc).ListYears("123456789012") + require.Nil(t, err) + // S3 prefixes are strings; a stray directory must not surface as a year. + assert.Equal(t, []int{2025, 2026}, years) +} + +func TestDiscovery_EmptyLayout(t *testing.T) { + // An empty account list is the clearest signal that the bucket or report + // prefix is wrong, so it must come back as an empty result rather than an + // error. + discovery := discoveryFixture(&prefixMockS3{}) + + accounts, err := discovery.ListAccounts() + require.Nil(t, err) + assert.Empty(t, accounts) +} + +// Per-stream counts separate three situations that a single pass/fail verdict +// cannot: a wrong prefix (zero everywhere), a dormant stream (zero for one type, +// which is what inline completions look like under agentic usage), and a +// permission gap on one bucket. +func TestDiscovery_CountStreams(t *testing.T) { + base := "user-report/AWSLogs/123456789012/KiroLogs/user_report/us-east-1/2026/07/" + logBase := "logging/AWSLogs/123456789012/KiroLogs/" + svc := &prefixMockS3{objects: map[string][]string{ + base: {"KIRO_CLI_x_user_report_1.csv", "KIRO_IDE_x_user_report_1.csv"}, + logBase + "GenerateAssistantResponse/us-east-1/2026/07/": {"a.json.gz", "b.json.gz", "c.json.gz"}, + // GenerateCompletions intentionally absent: dormant, not misconfigured. + }} + + month := 7 + counts := discoveryFixture(svc).CountStreams("123456789012", 2026, &month, 0) + require.Len(t, counts, 3) + + byType := map[string]StreamCount{} + for _, c := range counts { + byType[c.FileType] = c + } + assert.Equal(t, 2, byType[models.FileTypeReport].Count) + assert.Equal(t, 3, byType[models.FileTypeChatLog].Count) + assert.Equal(t, 0, byType[models.FileTypeCompletionLog].Count) + + // The prefix is reported alongside the count so a zero can be checked + // against the bucket directly. + assert.Contains(t, byType[models.FileTypeReport].Prefix, "user_report/us-east-1/2026/07") + for _, c := range counts { + assert.NotEmpty(t, c.Bucket) + assert.Empty(t, c.Error) + } +} + +func TestCountObjects(t *testing.T) { + prefix := "p/" + svc := &prefixMockS3{objects: map[string][]string{ + // Only .csv and .json.gz are collectable; the count must match what + // would actually be collected, not every object present. + prefix: {"a.csv", "b.json.gz", "c.txt", "d", "e.zip"}, + }} + client := &KiroS3Client{S3: svc, Bucket: "b"} + + count, atLeast, err := client.CountObjects(prefix, 0) + require.Nil(t, err) + assert.Equal(t, 2, count) + assert.False(t, atLeast) + + // Counting stops at the cap so the check stays cheap on a large bucket; the + // flag marks the value as a floor. + count, atLeast, err = client.CountObjects(prefix, 1) + require.Nil(t, err) + assert.Equal(t, 1, count) + assert.True(t, atLeast) +} + +func TestListSubPrefixes_TrailingSlashHandling(t *testing.T) { + svc := &prefixMockS3{commonPrefixes: map[string][]string{ + "a/b/": {"x", "y"}, + }} + client := &KiroS3Client{S3: svc, Bucket: "bkt"} + + // A caller-supplied prefix may or may not end in a slash; both must resolve + // to the same listing, and the child names come back without the slash. + withSlash, err := client.ListSubPrefixes("a/b/") + require.Nil(t, err) + withoutSlash, err := client.ListSubPrefixes("a/b") + require.Nil(t, err) + + assert.Equal(t, []string{"x", "y"}, withSlash) + assert.Equal(t, withSlash, withoutSlash) +} diff --git a/backend/plugins/kiro/tasks/extractor.go b/backend/plugins/kiro/tasks/extractor.go new file mode 100644 index 00000000000..10867515005 --- /dev/null +++ b/backend/plugins/kiro/tasks/extractor.go @@ -0,0 +1,204 @@ +/* +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 ( + "time" + + "github.com/apache/incubator-devlake/core/dal" + "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/kiro/models" +) + +// parseFunc turns one downloaded object into batches of rows ready for +// insertion. It is a pure function so it can run on a worker goroutine without +// touching the database. +// +// The result is a slice of batches rather than one flat slice because GORM +// derives the target table from the element type: a mixed []interface{} fails +// with "Table not set". A report CSV yields two different models, so each model +// gets its own homogeneous batch. +type parseFunc func(data []byte, connectionId uint64, scopeId string) ([]rowBatch, errors.Error) + +// rowBatch is a set of rows that all belong to the same table. +type rowBatch struct { + // rows must be a typed slice (e.g. []*models.KiroChatLog), not + // []interface{}, so GORM can resolve the table. + rows interface{} + count int +} + +// parsedFile is a worker's output, handed to the main goroutine for persistence. +type parsedFile struct { + meta *models.KiroS3FileMeta + batches []rowBatch + err errors.Error +} + +// rowCount totals the rows across every batch, for the cursor's record count. +func (p parsedFile) rowCount() int { + total := 0 + for _, batch := range p.batches { + total += batch.count + } + return total +} + +// pendingFiles returns the files still awaiting extraction for a scope. +// +// Files that have already failed MaxAttempts times are excluded. Without that +// bound, one permanently malformed object is retried on every run: the log fills +// with the same error and the scope never reaches a finished state. +func pendingFiles(db dal.Dal, connectionId uint64, scopeId string, fileType string) ([]models.KiroS3FileMeta, errors.Error) { + var files []models.KiroS3FileMeta + err := db.All(&files, + dal.From(&models.KiroS3FileMeta{}), + dal.Where("connection_id = ? AND scope_id = ? AND file_type = ? AND processed = ? AND attempt_count < ?", + connectionId, scopeId, fileType, false, models.MaxAttempts), + ) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to query pending kiro files") + } + return files, nil +} + +// extractFiles downloads and parses every pending file of one type, then stores +// the results. +// +// Downloads run concurrently but all database writes happen on the calling +// goroutine. That split is a correctness requirement, not an optimization: +// twenty workers inserting into the same table concurrently deadlock on MySQL +// gap locks, and DevLake's retry layer would turn those deadlocks into +// intermittent failures that only appear under load. +func extractFiles(taskCtx plugin.SubTaskContext, fileType string, parse parseFunc) errors.Error { + data := taskCtx.GetData().(*KiroTaskData) + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + + files, err := pendingFiles(db, data.Options.ConnectionId, data.Options.ScopeId, fileType) + if err != nil { + return err + } + if len(files) == 0 { + logger.Info("no pending %s files for scope %s", fileType, data.Options.ScopeId) + return nil + } + + logger.Info("extracting %d %s files", len(files), fileType) + taskCtx.SetProgress(0, len(files)) + + client := data.S3Clients.ForFileType(fileType) + results := make(chan parsedFile, len(files)) + + scheduler, err := helper.NewWorkerScheduler( + taskCtx.GetContext(), + data.WorkerCount(), + // The tick is a global rate limiter, not a per-worker one: every task + // waits for one tick before running, so this value caps total + // throughput regardless of pool size. A one-second tick would pin the + // whole run to one file per second and leave the pool idle - measured + // at 13 seconds for 13 files. Keep it well below the per-object fetch + // time so the workers, not the ticker, set the pace. + time.Millisecond, + logger, + ) + if err != nil { + return err + } + defer scheduler.Release() + + for i := range files { + meta := files[i] + scheduler.SubmitBlocking(func() errors.Error { + // Workers only fetch and parse. Anything touching the database + // happens after WaitAsync below. + body, getErr := client.GetObjectBytes(meta.S3Path) + if getErr != nil { + results <- parsedFile{meta: &meta, err: getErr} + return nil + } + batches, parseErr := parse(body, data.Options.ConnectionId, data.Options.ScopeId) + results <- parsedFile{meta: &meta, batches: batches, err: parseErr} + return nil + }) + } + + if waitErr := scheduler.WaitAsync(); waitErr != nil { + return waitErr + } + close(results) + + for result := range results { + if saveErr := persistFile(db, result); saveErr != nil { + return saveErr + } + taskCtx.IncProgress(1) + } + + return nil +} + +// persistFile stores one file's rows and updates its cursor entry. +// +// A parse or download failure is recorded rather than aborting the run: one bad +// object should not block the rest of the scope. The reason is written to +// error_message so a short month can be explained with a query instead of +// guesswork. +func persistFile(db dal.Dal, result parsedFile) errors.Error { + meta := result.meta + + if result.err != nil { + meta.AttemptCount++ + meta.ErrorMessage = result.err.Error() + // Processed stays false so the file is retried, up to MaxAttempts. + if err := db.Update(meta); err != nil { + return errors.Default.Wrap(err, "failed to record kiro extraction failure") + } + return nil + } + + // Each batch is written separately so GORM sees a typed slice and can + // resolve the target table. + for _, batch := range result.batches { + if batch.count == 0 { + continue + } + if err := db.CreateOrUpdate(batch.rows); err != nil { + // A write failure counts as an attempt too, otherwise a row that + // cannot be stored would be fetched forever. + meta.AttemptCount++ + meta.ErrorMessage = err.Error() + if updateErr := db.Update(meta); updateErr != nil { + return errors.Default.Wrap(updateErr, "failed to record kiro write failure") + } + return nil + } + } + + now := time.Now() + meta.Processed = true + meta.ProcessedTime = &now + meta.RecordCount = result.rowCount() + meta.ErrorMessage = "" + if err := db.Update(meta); err != nil { + return errors.Default.Wrap(err, "failed to mark kiro file processed") + } + return nil +} diff --git a/backend/plugins/kiro/tasks/extractor_test.go b/backend/plugins/kiro/tasks/extractor_test.go new file mode 100644 index 00000000000..5eec6051e3b --- /dev/null +++ b/backend/plugins/kiro/tasks/extractor_test.go @@ -0,0 +1,184 @@ +/* +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 ( + "os" + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/plugins/kiro/models" +) + +// GORM resolves the destination table from a slice's element type, so every +// batch must be a typed slice. Passing []interface{} fails at runtime with +// "Table not set" - a real bug caught only by running against a database, since +// it type-checks fine. These assertions pin the requirement so a future +// refactor back to a flat interface slice fails here instead of in production. +func assertTypedBatch[T any](t *testing.T, batch rowBatch, wantCount int) { + t.Helper() + rows, ok := batch.rows.([]T) + require.True(t, ok, "batch must carry a typed slice, got %T", batch.rows) + assert.Len(t, rows, wantCount) + assert.Equal(t, wantCount, batch.count) +} + +// The adapters exist so one parse produces everything a file yields; these +// verify the adaptation rather than the parsing, which is covered directly. +func TestParseUserReportRows_ReturnsTwoTypedBatches(t *testing.T) { + data := loadReportFixture(t, "02_standard_cli.csv") + + batches, err := parseUserReportRows(data, testConnectionId, testScopeId) + require.Nil(t, err) + + // Two batches, not one mixed slice: a report CSV yields two different + // models, and GORM cannot infer a table from a heterogeneous slice. + require.Len(t, batches, 2) + assertTypedBatch[*models.KiroUserReport](t, batches[0], 1) + assertTypedBatch[*models.KiroUserModelMessage](t, batches[1], 1) +} + +func TestParseLogRows(t *testing.T) { + t.Run("chat log", func(t *testing.T) { + batches, err := parseChatLogRows(loadLogFixture(t, "chat_03_two_records.json.gz"), testConnectionId, testScopeId) + require.Nil(t, err) + require.Len(t, batches, 1) + assertTypedBatch[*models.KiroChatLog](t, batches[0], 2) + }) + + t.Run("completion log", func(t *testing.T) { + batches, err := parseCompletionLogRows(loadLogFixture(t, "completion_01_non_empty.json.gz"), testConnectionId, testScopeId) + require.Nil(t, err) + require.Len(t, batches, 1) + assertTypedBatch[*models.KiroCompletionLog](t, batches[0], 1) + }) + + // A record with no completions still produces a row: those records are the + // denominator for what Kiro offered versus what was taken. + t.Run("empty completions still produce a row", func(t *testing.T) { + batches, err := parseCompletionLogRows(loadLogFixture(t, "completion_02_empty.json.gz"), testConnectionId, testScopeId) + require.Nil(t, err) + require.Len(t, batches, 1) + assertTypedBatch[*models.KiroCompletionLog](t, batches[0], 1) + }) + + t.Run("parse failure propagates", func(t *testing.T) { + _, err := parseChatLogRows([]byte("not gzipped"), testConnectionId, testScopeId) + assert.NotNil(t, err) + }) +} + +// The scheduler's tick is a global rate limiter rather than a per-worker one: +// every submitted task waits one tick before running, so the interval caps total +// throughput no matter how large the pool is. A one-second tick pinned a real run +// to one file per second - 13 seconds for 13 files, with 20 workers idle. +func TestSchedulerTickDoesNotThrottleThePool(t *testing.T) { + source, err := os.ReadFile("extractor.go") + require.NoError(t, err) + + tick := regexp.MustCompile(`NewWorkerScheduler\((?s:.*?)time\.(\w+),`).FindStringSubmatch(string(source)) + require.NotNil(t, tick, "expected a tick interval passed to NewWorkerScheduler") + assert.Equal(t, "Millisecond", tick[1], + "a coarser tick throttles the whole pool to one object per tick") +} + +// Twenty workers inserting into one table concurrently deadlock on MySQL gap +// locks, and DevLake's retry layer turns those deadlocks into failures that only +// appear under load - the hardest kind to reproduce. The worker closure must +// therefore stay free of database access, which is asserted structurally because +// no unit test can observe the absence of a write. +func TestExtractorWorkersDoNotTouchTheDatabase(t *testing.T) { + source, err := os.ReadFile("extractor.go") + require.NoError(t, err) + + body := string(source) + workerStart := regexp.MustCompile(`scheduler\.SubmitBlocking\(func\(\) errors\.Error \{`).FindStringIndex(body) + require.NotNil(t, workerStart, "expected a worker closure submitted to the scheduler") + + // Take the closure body up to the WaitAsync call, which is where the main + // goroutine resumes and persistence begins. + waitIdx := regexp.MustCompile(`scheduler\.WaitAsync\(\)`).FindStringIndex(body) + require.NotNil(t, waitIdx) + require.Less(t, workerStart[1], waitIdx[0]) + workerRegion := body[workerStart[1]:waitIdx[0]] + + for _, forbidden := range []string{"db.Create", "db.Update", "db.CreateOrUpdate", "db.All", "db.First", "db.Exec"} { + assert.NotContains(t, workerRegion, forbidden, + "worker goroutines must not access the database; %s belongs on the main goroutine", forbidden) + } +} + +// A permanently malformed object would otherwise be retried on every run, +// filling the log with the same error while the scope never reaches a finished +// state. +func TestPendingFilesQueryBoundsRetries(t *testing.T) { + source, err := os.ReadFile("extractor.go") + require.NoError(t, err) + + clauses := regexp.MustCompile(`dal\.Where\(\s*"([^"]+)"`).FindAllStringSubmatch(string(source), -1) + require.NotEmpty(t, clauses) + + var found bool + for _, clause := range clauses { + if regexp.MustCompile(`attempt_count\s*<`).MatchString(clause[1]) { + found = true + // The same query must also scope to the connection and scope, or one + // scope's run would pick up another's files. + assert.Contains(t, clause[1], "connection_id") + assert.Contains(t, clause[1], "scope_id") + assert.Contains(t, clause[1], "file_type") + } + } + assert.True(t, found, "the pending-files query must bound attempt_count") +} + +func TestMaxAttemptsIsBounded(t *testing.T) { + // A cap that is zero or negative would stop all extraction; an unbounded one + // would never converge. + assert.Greater(t, models.MaxAttempts, 0) + assert.LessOrEqual(t, models.MaxAttempts, 10) +} + +// Every extractor must declare the collector as a dependency: without the file +// metadata rows there is nothing to extract, and DevLake would otherwise be free +// to schedule extraction before discovery. +func TestExtractorSubTaskMetas(t *testing.T) { + for _, meta := range []plugin.SubTaskMeta{ + ExtractKiroUserReportMeta, + ExtractKiroChatLogMeta, + ExtractKiroCompletionLogMeta, + } { + t.Run(meta.Name, func(t *testing.T) { + assert.NotEmpty(t, meta.Name) + assert.NotNil(t, meta.EntryPoint) + assert.True(t, meta.EnabledByDefault) + + var dependsOnCollector bool + for _, dep := range meta.Dependencies { + if dep.Name == CollectKiroS3FilesMeta.Name { + dependsOnCollector = true + } + } + assert.True(t, dependsOnCollector, "extraction depends on file discovery having run") + }) + } +} diff --git a/backend/plugins/kiro/tasks/identity_client.go b/backend/plugins/kiro/tasks/identity_client.go new file mode 100644 index 00000000000..8e6ed34c2fd --- /dev/null +++ b/backend/plugins/kiro/tasks/identity_client.go @@ -0,0 +1,93 @@ +/* +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 ( + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/identitystore" + + "github.com/apache/incubator-devlake/plugins/kiro/models" +) + +// IdentityStoreAPI is the subset of the Identity Store API used here. +type IdentityStoreAPI interface { + DescribeUser(input *identitystore.DescribeUserInput) (*identitystore.DescribeUserOutput, error) +} + +// KiroIdentityClient resolves user ids to human-readable display names. +// +// This is entirely optional. Identity for joining to git history comes from the +// User_Email column of the report CSV, so a missing or misconfigured Identity +// Store degrades only the display name, never the data pipeline. +type KiroIdentityClient struct { + IdentityStore IdentityStoreAPI + StoreId string +} + +// NewKiroIdentityClient returns nil when Identity Store is not configured, +// which callers treat as "no display names" rather than as an error. +func NewKiroIdentityClient(connection *models.KiroConnection) (*KiroIdentityClient, error) { + if connection.IdentityStoreId == "" || connection.IdentityStoreRegion == "" { + return nil, nil + } + + sess, err := session.NewSession(&aws.Config{ + Region: aws.String(connection.IdentityStoreRegion), + Credentials: credentials.NewStaticCredentials( + connection.AccessKeyId, + connection.SecretAccessKey, + "", + ), + }) + if err != nil { + return nil, err + } + + return &KiroIdentityClient{ + IdentityStore: identitystore.New(sess), + StoreId: connection.IdentityStoreId, + }, nil +} + +// ResolveDisplayName looks up a display name, returning nil when it cannot be +// determined. +// +// nil rather than the raw user id: the column exists for human readability, and +// storing an id there would make it look like a resolved name. +func (client *KiroIdentityClient) ResolveDisplayName(userId string) (*string, error) { + if client == nil || client.IdentityStore == nil || userId == "" { + return nil, nil + } + + result, err := client.IdentityStore.DescribeUser(&identitystore.DescribeUserInput{ + IdentityStoreId: aws.String(client.StoreId), + UserId: aws.String(userId), + }) + if err != nil { + // Surfaced for logging, but callers proceed without a display name. + return nil, err + } + + if result.DisplayName != nil && *result.DisplayName != "" { + name := *result.DisplayName + return &name, nil + } + return nil, nil +} diff --git a/backend/plugins/kiro/tasks/log_extractor.go b/backend/plugins/kiro/tasks/log_extractor.go new file mode 100644 index 00000000000..476b06add9c --- /dev/null +++ b/backend/plugins/kiro/tasks/log_extractor.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 tasks + +import ( + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/plugins/kiro/models" +) + +var ( + _ plugin.SubTaskEntryPoint = ExtractKiroChatLog + _ plugin.SubTaskEntryPoint = ExtractKiroCompletionLog +) + +var ExtractKiroChatLogMeta = plugin.SubTaskMeta{ + Name: "extractKiroChatLog", + EntryPoint: ExtractKiroChatLog, + EnabledByDefault: true, + Description: "Extract Kiro chat interactions from GenerateAssistantResponse logs", + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Dependencies: []*plugin.SubTaskMeta{&CollectKiroS3FilesMeta}, +} + +var ExtractKiroCompletionLogMeta = plugin.SubTaskMeta{ + Name: "extractKiroCompletionLog", + EntryPoint: ExtractKiroCompletionLog, + EnabledByDefault: true, + Description: "Extract Kiro inline suggestions from GenerateCompletions logs", + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Dependencies: []*plugin.SubTaskMeta{&CollectKiroS3FilesMeta}, +} + +// ExtractKiroChatLog loads the chat interaction logs for this scope. +// +// This is the high-volume stream: one object per interaction, roughly 700 bytes +// each, which for a single active user reaches several hundred objects a day. +// The cost is request count rather than bytes, which is what the worker pool +// addresses. +func ExtractKiroChatLog(taskCtx plugin.SubTaskContext) errors.Error { + return extractFiles(taskCtx, models.FileTypeChatLog, parseChatLogRows) +} + +// ExtractKiroCompletionLog loads the inline suggestion logs for this scope. +// +// This stream is dormant under agentic usage - the sampled history stops in +// March - but the objects remain in S3, so a backfill picks them up and a team +// that enables IDE inline completion starts producing them again. +func ExtractKiroCompletionLog(taskCtx plugin.SubTaskContext) errors.Error { + return extractFiles(taskCtx, models.FileTypeCompletionLog, parseCompletionLogRows) +} + +// Both adapters return a single typed batch: the slice keeps its concrete +// element type so GORM can resolve the table. +func parseChatLogRows(data []byte, connectionId uint64, scopeId string) ([]rowBatch, errors.Error) { + logs, err := ParseChatLog(data, connectionId, scopeId) + if err != nil { + return nil, err + } + return []rowBatch{{rows: logs, count: len(logs)}}, nil +} + +func parseCompletionLogRows(data []byte, connectionId uint64, scopeId string) ([]rowBatch, errors.Error) { + logs, err := ParseCompletionLog(data, connectionId, scopeId) + if err != nil { + return nil, err + } + return []rowBatch{{rows: logs, count: len(logs)}}, nil +} diff --git a/backend/plugins/kiro/tasks/log_parser.go b/backend/plugins/kiro/tasks/log_parser.go new file mode 100644 index 00000000000..3de9a66fdea --- /dev/null +++ b/backend/plugins/kiro/tasks/log_parser.go @@ -0,0 +1,256 @@ +/* +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 ( + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "path/filepath" + "strings" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/plugins/kiro/models" +) + +// Markers used by the prompt heuristics. These read a prompt's text for signs +// of how the developer invoked Kiro; they are only meaningful when a prompt is +// actually present. +const ( + steeringMarker = ".kiro/steering" + specMarker = ".kiro/specs" +) + +// Every field below is a pointer or checked for presence because Kiro omits +// empty keys entirely rather than emitting a null. Across 4530 sampled records, +// five documented fields never appeared at all and modelId appeared on only 45%, +// so "absent" and "zero" have to stay distinguishable. +type chatLogFile struct { + Records []struct { + Request *struct { + Prompt *string `json:"prompt"` + ChatTriggerType *string `json:"chatTriggerType"` + UserId *string `json:"userId"` + TimeStamp *string `json:"timeStamp"` + ModelId *string `json:"modelId"` + CustomizationArn *string `json:"customizationArn"` + } `json:"generateAssistantResponseEventRequest"` + Response *struct { + AssistantResponse *string `json:"assistantResponse"` + FollowupPrompts *string `json:"followupPrompts"` + RequestId *string `json:"requestId"` + MessageMetadata *struct { + ConversationId *string `json:"conversationId"` + UtteranceId *string `json:"utteranceId"` + } `json:"messageMetadata"` + } `json:"generateAssistantResponseEventResponse"` + } `json:"records"` +} + +type completionLogFile struct { + Records []struct { + Request *struct { + LeftContext *string `json:"leftContext"` + RightContext *string `json:"rightContext"` + FileName *string `json:"fileName"` + UserId *string `json:"userId"` + TimeStamp *string `json:"timeStamp"` + CustomizationArn *string `json:"customizationArn"` + } `json:"generateCompletionsEventRequest"` + Response *struct { + Completions []string `json:"completions"` + RequestId *string `json:"requestId"` + } `json:"generateCompletionsEventResponse"` + } `json:"records"` +} + +// ParseChatLog parses a gzipped GenerateAssistantResponse log. +// +// Neither the prompt nor the assistant response text is persisted: derived +// features are computed here and the originals are dropped, which keeps +// proprietary code and personal content out of the warehouse. +// +// A file holds one or two records, so the array is always walked. +func ParseChatLog(gzData []byte, connectionId uint64, scopeId string) ([]*models.KiroChatLog, errors.Error) { + raw, err := gunzip(gzData) + if err != nil { + return nil, err + } + + var parsed chatLogFile + if jsonErr := json.Unmarshal(raw, &parsed); jsonErr != nil { + return nil, errors.Default.Wrap(jsonErr, "failed to unmarshal chat log") + } + + var result []*models.KiroChatLog + for _, record := range parsed.Records { + if record.Request == nil || record.Response == nil { + // Without both halves there is no usable interaction. + continue + } + requestId := deref(record.Response.RequestId) + if requestId == "" { + // requestId is the primary key; a record without one cannot be + // stored or deduplicated. + continue + } + + userId, identityStoreId := SplitUserId(deref(record.Request.UserId)) + + timestamp, tsErr := ParseKiroTime(deref(record.Request.TimeStamp)) + if tsErr != nil { + return nil, tsErr + } + + log := &models.KiroChatLog{ + ConnectionId: connectionId, + ScopeId: scopeId, + RequestId: requestId, + UserId: userId, + IdentityStoreId: identityStoreId, + Timestamp: timestamp, + ChatTriggerType: deref(record.Request.ChatTriggerType), + // Only ~45% of records carry a model id, so it stays nil when + // absent. Attributing model usage from this column would skew any + // share-of-usage figure; user_model_messages is authoritative. + ModelId: record.Request.ModelId, + ResponseLength: len(deref(record.Response.AssistantResponse)), + // Presence, not content: the follow-up text itself is not stored. + HasFollowupPrompts: record.Response.FollowupPrompts != nil, + } + + prompt := deref(record.Request.Prompt) + if prompt != "" { + // A non-empty prompt means the user spoke this turn. + sum := sha256.Sum256([]byte(prompt)) + hash := hex.EncodeToString(sum[:]) + hasSteering := strings.Contains(prompt, steeringMarker) + isSpecMode := strings.Contains(prompt, specMarker) + + log.HasPrompt = true + log.PromptLength = len(prompt) + log.PromptSha256 = &hash + log.HasSteering = &hasSteering + log.IsSpecMode = &isSpecMode + } + // When the prompt is empty the agent continued on its own. The hash and + // both heuristics stay nil: hashing the empty string would give roughly + // 71% of rows one identical hash and destroy the rework signal, and a + // false heuristic would be indistinguishable from a real negative. + + if md := record.Response.MessageMetadata; md != nil { + log.ConversationId = md.ConversationId + log.UtteranceId = md.UtteranceId + } + + result = append(result, log) + } + + return result, nil +} + +// ParseCompletionLog parses a gzipped GenerateCompletions log. +// +// The counters are named Returned* rather than Accepted*: a record is written +// when the suggestion is requested, and an empty completions array is common, so +// these measure what Kiro offered rather than what was taken. Records with no +// completions are still stored - they are the denominator. +func ParseCompletionLog(gzData []byte, connectionId uint64, scopeId string) ([]*models.KiroCompletionLog, errors.Error) { + raw, err := gunzip(gzData) + if err != nil { + return nil, err + } + + var parsed completionLogFile + if jsonErr := json.Unmarshal(raw, &parsed); jsonErr != nil { + return nil, errors.Default.Wrap(jsonErr, "failed to unmarshal completion log") + } + + var result []*models.KiroCompletionLog + for _, record := range parsed.Records { + if record.Request == nil || record.Response == nil { + continue + } + requestId := deref(record.Response.RequestId) + if requestId == "" { + continue + } + + userId, identityStoreId := SplitUserId(deref(record.Request.UserId)) + + timestamp, tsErr := ParseKiroTime(deref(record.Request.TimeStamp)) + if tsErr != nil { + return nil, tsErr + } + + // fileName is a bare file name with no directory path, so it cannot be + // resolved to a unique repository file. + fileName := deref(record.Request.FileName) + + charCount := 0 + lineCount := 0 + for _, completion := range record.Response.Completions { + charCount += len(completion) + lineCount += strings.Count(completion, "\n") + 1 + } + + result = append(result, &models.KiroCompletionLog{ + ConnectionId: connectionId, + ScopeId: scopeId, + RequestId: requestId, + UserId: userId, + IdentityStoreId: identityStoreId, + Timestamp: timestamp, + FileName: fileName, + FileExtension: strings.TrimPrefix(filepath.Ext(fileName), "."), + // Present on completion records but never on chat records. + HasCustomization: record.Request.CustomizationArn != nil, + CompletionsCount: len(record.Response.Completions), + ReturnedCharCount: charCount, + ReturnedLineCount: lineCount, + LeftContextLength: len(deref(record.Request.LeftContext)), + RightContextLength: len(deref(record.Request.RightContext)), + }) + } + + return result, nil +} + +func gunzip(gzData []byte) ([]byte, errors.Error) { + reader, err := gzip.NewReader(bytes.NewReader(gzData)) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to open gzip reader") + } + defer reader.Close() + + raw, err := io.ReadAll(reader) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to decompress log file") + } + return raw, nil +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/backend/plugins/kiro/tasks/log_parser_test.go b/backend/plugins/kiro/tasks/log_parser_test.go new file mode 100644 index 00000000000..f883569fecd --- /dev/null +++ b/backend/plugins/kiro/tasks/log_parser_test.go @@ -0,0 +1,319 @@ +/* +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 ( + "bytes" + "compress/gzip" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Fixtures are real log files whose text content was replaced but whose exact +// string lengths and line structure were preserved, because those are the +// values the parser derives and this test asserts. +func loadLogFixture(t *testing.T, name string) []byte { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", "logs", name)) + require.NoError(t, err) + return data +} + +func gzipBytes(t *testing.T, s string) []byte { + t.Helper() + var buf bytes.Buffer + w := gzip.NewWriter(&buf) + _, err := w.Write([]byte(s)) + require.NoError(t, err) + require.NoError(t, w.Close()) + return buf.Bytes() +} + +// An empty prompt means the agent continued on its own after a tool call rather +// than the user speaking. This is the majority case - roughly 71% of sampled +// records - and it must not be confused with a user turn. +func TestParseChatLog_EmptyPrompt(t *testing.T) { + logs, err := ParseChatLog(loadLogFixture(t, "chat_01_empty_prompt.json.gz"), testConnectionId, testScopeId) + require.Nil(t, err) + require.Len(t, logs, 1) + + l := logs[0] + assert.False(t, l.HasPrompt) + assert.Equal(t, 0, l.PromptLength) + // Hashing the empty string would give ~71% of rows one shared hash and + // destroy the rework signal, so it stays NULL. + assert.Nil(t, l.PromptSha256) + // Both heuristics read prompt text, so without a prompt they are unknown + // rather than false. + assert.Nil(t, l.HasSteering) + assert.Nil(t, l.IsSpecMode) + + assert.Equal(t, "64d13ea7-dff5-4563-9285-6a9e351e87a0", l.RequestId) + assert.Equal(t, "MANUAL", l.ChatTriggerType) + require.NotNil(t, l.ModelId) + assert.Equal(t, "claude-opus-5", *l.ModelId) + assert.Equal(t, 259, l.ResponseLength) + + // Log records always carry the identity-store prefix; stripping it is what + // lets this join against the report table. + assert.Equal(t, "11111111-1111-4111-8111-111111111111", l.UserId) + assert.Equal(t, "d-1234567890", l.IdentityStoreId) + + // Nanosecond input truncated for DATETIME(6). + assert.Equal(t, 2026, l.Timestamp.Year()) + assert.Equal(t, 23, l.Timestamp.Hour()) + assert.Equal(t, 0, l.Timestamp.Nanosecond()%1000) + + // Documented but never observed, so they stay NULL - their absence is why + // the S3 logs cannot group interactions into sessions. + assert.Nil(t, l.ConversationId) + assert.Nil(t, l.UtteranceId) +} + +func TestParseChatLog_WithPrompt(t *testing.T) { + logs, err := ParseChatLog(loadLogFixture(t, "chat_02_with_prompt.json.gz"), testConnectionId, testScopeId) + require.Nil(t, err) + require.Len(t, logs, 1) + + l := logs[0] + assert.True(t, l.HasPrompt) + assert.Equal(t, 1363, l.PromptLength) + require.NotNil(t, l.PromptSha256) + assert.Len(t, *l.PromptSha256, 64) + // With a prompt present the heuristics carry a real verdict. + require.NotNil(t, l.HasSteering) + require.NotNil(t, l.IsSpecMode) + assert.Equal(t, 64, l.ResponseLength) +} + +// Kiro packs one or two records per file, so the array is always walked. +func TestParseChatLog_TwoRecords(t *testing.T) { + logs, err := ParseChatLog(loadLogFixture(t, "chat_03_two_records.json.gz"), testConnectionId, testScopeId) + require.Nil(t, err) + require.Len(t, logs, 2) + + assert.NotEqual(t, logs[0].RequestId, logs[1].RequestId) + // A zero-length assistant response is real data, not a parse failure. + assert.Equal(t, 0, logs[0].ResponseLength) + assert.Equal(t, 117, logs[1].ResponseLength) +} + +// modelId is absent on more than half of all records. Storing an empty string +// instead of NULL would invent a phantom model accounting for most of the +// traffic. +func TestParseChatLog_MissingModelId(t *testing.T) { + logs, err := ParseChatLog(loadLogFixture(t, "chat_04_no_model_id.json.gz"), testConnectionId, testScopeId) + require.Nil(t, err) + require.Len(t, logs, 1) + + l := logs[0] + assert.Nil(t, l.ModelId, "an absent modelId must be NULL, never an empty string") + // This record does carry followupPrompts, which a single-day sample had + // suggested never appears. + assert.True(t, l.HasFollowupPrompts) + assert.True(t, l.HasPrompt) + assert.Equal(t, 175, l.PromptLength) +} + +func TestParseChatLog_PromptHashing(t *testing.T) { + // The same prompt text must hash identically, which is what makes repeated + // submission detectable as a rework signal. + body := `{"records":[{"generateAssistantResponseEventRequest":` + + `{"prompt":"fix the retry logic","chatTriggerType":"MANUAL",` + + `"userId":"d-abc.user-1","timeStamp":"2026-07-27T23:03:29.027400929Z"},` + + `"generateAssistantResponseEventResponse":{"assistantResponse":"ok","requestId":"%s"}}]}` + + first, err := ParseChatLog(gzipBytes(t, fmt.Sprintf(body, "req-1")), testConnectionId, testScopeId) + require.Nil(t, err) + second, err := ParseChatLog(gzipBytes(t, fmt.Sprintf(body, "req-2")), testConnectionId, testScopeId) + require.Nil(t, err) + + require.Len(t, first, 1) + require.Len(t, second, 1) + require.NotNil(t, first[0].PromptSha256) + require.NotNil(t, second[0].PromptSha256) + assert.Equal(t, *first[0].PromptSha256, *second[0].PromptSha256) + assert.NotEqual(t, first[0].RequestId, second[0].RequestId) +} + +func TestParseChatLog_Heuristics(t *testing.T) { + tests := []struct { + name string + prompt string + wantHasSteering bool + wantIsSpecMode bool + }{ + {"steering reference", "please follow .kiro/steering/style.md", true, false}, + {"spec reference", "implement .kiro/specs/auth/tasks.md", false, true}, + {"both", "read .kiro/steering and .kiro/specs", true, true}, + {"neither", "just fix this bug", false, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := `{"records":[{"generateAssistantResponseEventRequest":` + + `{"prompt":"` + tt.prompt + `","chatTriggerType":"MANUAL",` + + `"userId":"d-abc.user-1","timeStamp":"2026-07-27T23:03:29Z"},` + + `"generateAssistantResponseEventResponse":{"assistantResponse":"ok","requestId":"r1"}}]}` + logs, err := ParseChatLog(gzipBytes(t, body), testConnectionId, testScopeId) + require.Nil(t, err) + require.Len(t, logs, 1) + require.NotNil(t, logs[0].HasSteering) + require.NotNil(t, logs[0].IsSpecMode) + assert.Equal(t, tt.wantHasSteering, *logs[0].HasSteering) + assert.Equal(t, tt.wantIsSpecMode, *logs[0].IsSpecMode) + }) + } +} + +func TestParseCompletionLog_NonEmpty(t *testing.T) { + logs, err := ParseCompletionLog(loadLogFixture(t, "completion_01_non_empty.json.gz"), testConnectionId, testScopeId) + require.Nil(t, err) + require.Len(t, logs, 1) + + l := logs[0] + assert.Equal(t, "e0d1e760-a907-47d4-baa5-93df0f38b274", l.RequestId) + // A bare file name with no directory path, so it cannot be resolved to a + // unique repository file. + assert.Equal(t, "mcp.json", l.FileName) + assert.Equal(t, "json", l.FileExtension) + assert.Equal(t, 1, l.CompletionsCount) + assert.Equal(t, 347, l.ReturnedCharCount) + assert.Equal(t, 15, l.ReturnedLineCount) + assert.Equal(t, 5324, l.LeftContextLength) + assert.Equal(t, 12, l.RightContextLength) + assert.False(t, l.HasCustomization) + assert.Equal(t, "11111111-1111-4111-8111-111111111111", l.UserId) +} + +// A completion record is written when the suggestion is requested, not when it +// is accepted, so an empty array is normal. The record is the denominator and +// must still be stored. +func TestParseCompletionLog_EmptyCompletions(t *testing.T) { + logs, err := ParseCompletionLog(loadLogFixture(t, "completion_02_empty.json.gz"), testConnectionId, testScopeId) + require.Nil(t, err) + require.Len(t, logs, 1, "a record with no completions is still stored") + + l := logs[0] + assert.Equal(t, 0, l.CompletionsCount) + assert.Equal(t, 0, l.ReturnedCharCount) + assert.Equal(t, 0, l.ReturnedLineCount) + // Context was still sent, which is what distinguishes this from a truncated + // record. + assert.Equal(t, 5323, l.LeftContextLength) +} + +func TestParseCompletionLog_LineCounting(t *testing.T) { + tests := []struct { + name string + completions string + wantLines int + wantChars int + }{ + {"single line", `["abc"]`, 1, 3}, + {"two lines", `["a\nb"]`, 2, 3}, + {"trailing newline counts the empty final line", `["a\n"]`, 2, 2}, + {"two completions summed", `["a\nb","c"]`, 3, 4}, + {"empty array", `[]`, 0, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := `{"records":[{"generateCompletionsEventRequest":` + + `{"fileName":"f.ts","leftContext":"","rightContext":"",` + + `"userId":"d-abc.user-1","timeStamp":"2026-03-19T13:49:58Z"},` + + `"generateCompletionsEventResponse":{"completions":` + tt.completions + `,"requestId":"r1"}}]}` + logs, err := ParseCompletionLog(gzipBytes(t, body), testConnectionId, testScopeId) + require.Nil(t, err) + require.Len(t, logs, 1) + assert.Equal(t, tt.wantLines, logs[0].ReturnedLineCount) + assert.Equal(t, tt.wantChars, logs[0].ReturnedCharCount) + }) + } +} + +func TestParseCompletionLog_Customization(t *testing.T) { + // customizationArn appears on completion records but never on chat records. + body := `{"records":[{"generateCompletionsEventRequest":` + + `{"fileName":"f.ts","userId":"d-abc.u1","timeStamp":"2026-03-19T13:49:58Z",` + + `"customizationArn":"arn:aws:codewhisperer:us-east-1:1:customization/abc"},` + + `"generateCompletionsEventResponse":{"completions":[],"requestId":"r1"}}]}` + logs, err := ParseCompletionLog(gzipBytes(t, body), testConnectionId, testScopeId) + require.Nil(t, err) + require.Len(t, logs, 1) + assert.True(t, logs[0].HasCustomization) +} + +func TestParseLog_EdgeCases(t *testing.T) { + t.Run("empty records array", func(t *testing.T) { + logs, err := ParseChatLog(gzipBytes(t, `{"records":[]}`), testConnectionId, testScopeId) + assert.Nil(t, err) + assert.Empty(t, logs) + }) + + t.Run("record without requestId is skipped", func(t *testing.T) { + // requestId is the primary key, so such a record cannot be stored or + // deduplicated. + body := `{"records":[{"generateAssistantResponseEventRequest":` + + `{"prompt":"","userId":"d-abc.u1","timeStamp":"2026-07-27T23:03:29Z"},` + + `"generateAssistantResponseEventResponse":{"assistantResponse":"ok"}}]}` + logs, err := ParseChatLog(gzipBytes(t, body), testConnectionId, testScopeId) + assert.Nil(t, err) + assert.Empty(t, logs) + }) + + t.Run("record missing the response half is skipped", func(t *testing.T) { + body := `{"records":[{"generateAssistantResponseEventRequest":` + + `{"prompt":"x","userId":"d-abc.u1","timeStamp":"2026-07-27T23:03:29Z"}}]}` + logs, err := ParseChatLog(gzipBytes(t, body), testConnectionId, testScopeId) + assert.Nil(t, err) + assert.Empty(t, logs) + }) + + t.Run("bad timestamp is an error", func(t *testing.T) { + body := `{"records":[{"generateAssistantResponseEventRequest":` + + `{"prompt":"","userId":"d-abc.u1","timeStamp":"not-a-time"},` + + `"generateAssistantResponseEventResponse":{"assistantResponse":"ok","requestId":"r1"}}]}` + _, err := ParseChatLog(gzipBytes(t, body), testConnectionId, testScopeId) + assert.NotNil(t, err) + }) + + t.Run("non-gzip input is an error", func(t *testing.T) { + _, err := ParseChatLog([]byte("not gzipped"), testConnectionId, testScopeId) + assert.NotNil(t, err) + }) + + t.Run("malformed json is an error", func(t *testing.T) { + _, err := ParseChatLog(gzipBytes(t, `{"records":`), testConnectionId, testScopeId) + assert.NotNil(t, err) + }) + + t.Run("connection and scope are propagated", func(t *testing.T) { + body := `{"records":[{"generateAssistantResponseEventRequest":` + + `{"prompt":"","userId":"d-abc.u1","timeStamp":"2026-07-27T23:03:29Z"},` + + `"generateAssistantResponseEventResponse":{"assistantResponse":"ok","requestId":"r1"}}]}` + logs, err := ParseChatLog(gzipBytes(t, body), 42, "scope-x") + require.Nil(t, err) + require.Len(t, logs, 1) + assert.Equal(t, uint64(42), logs[0].ConnectionId) + assert.Equal(t, "scope-x", logs[0].ScopeId) + }) +} diff --git a/backend/plugins/kiro/tasks/s3_client.go b/backend/plugins/kiro/tasks/s3_client.go new file mode 100644 index 00000000000..a0e2d19ba5a --- /dev/null +++ b/backend/plugins/kiro/tasks/s3_client.go @@ -0,0 +1,217 @@ +/* +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 ( + "io" + "sort" + "strings" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/s3" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/plugins/kiro/models" +) + +// S3API is the subset of the S3 API this plugin uses, declared as an interface +// so collectors and extractors can be tested without AWS. +type S3API interface { + ListObjectsV2(input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) + GetObject(input *s3.GetObjectInput) (*s3.GetObjectOutput, error) +} + +// KiroS3Client is bound to exactly one bucket. +// +// Kiro recommends keeping interaction logs in a bucket separate from the +// activity reports, and the two may carry different KMS keys and IAM +// conditions. One client per bucket keeps those permission boundaries distinct, +// so an access failure points at a specific bucket instead of an ambiguous +// request. +type KiroS3Client struct { + S3 S3API + Bucket string +} + +// KiroS3Clients holds the report and log clients for a connection. When no +// separate log bucket is configured both fields address the same bucket, so +// single-bucket and dual-bucket setups take the same code path everywhere else. +type KiroS3Clients struct { + Report *KiroS3Client + PromptLog *KiroS3Client +} + +// NewKiroS3Clients builds the client pair for a connection. +func NewKiroS3Clients(connection *models.KiroConnection) (*KiroS3Clients, errors.Error) { + sess, err := session.NewSession(&aws.Config{ + Region: aws.String(connection.Region), + Credentials: credentials.NewStaticCredentials(connection.AccessKeyId, connection.SecretAccessKey, ""), + }) + if err != nil { + return nil, errors.Convert(err) + } + + // A single S3 service client can address both buckets; the split is at the + // KiroS3Client level, which pins the bucket name. + svc := s3.New(sess) + + return &KiroS3Clients{ + Report: &KiroS3Client{S3: svc, Bucket: connection.Bucket}, + PromptLog: &KiroS3Client{S3: svc, Bucket: connection.GetPromptLogBucket()}, + }, nil +} + +// ForFileType returns the client that owns a given file type. +func (c *KiroS3Clients) ForFileType(fileType string) *KiroS3Client { + if fileType == models.FileTypeReport { + return c.Report + } + return c.PromptLog +} + +// Buckets returns the distinct buckets in use - one entry when reports and logs +// share a bucket, two when they do not. +func (c *KiroS3Clients) Buckets() []string { + if c.Report.Bucket == c.PromptLog.Bucket { + return []string{c.Report.Bucket} + } + return []string{c.Report.Bucket, c.PromptLog.Bucket} +} + +// ListSubPrefixes returns the immediate child "directories" under a prefix. +// +// Kiro's export layout is fully self-describing - accounts, years and months all +// appear as path segments - so this is what lets a scope be picked from what +// actually exists instead of typed by hand. A mistyped prefix is otherwise +// indistinguishable from a month with no data: collection succeeds and finds +// nothing either way. +// +// Uses a delimiter so S3 returns only the segment names, not every object +// beneath them. +func (c *KiroS3Client) ListSubPrefixes(prefix string) ([]string, errors.Error) { + if prefix != "" && !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + + var names []string + var continuationToken *string + for { + output, err := c.S3.ListObjectsV2(&s3.ListObjectsV2Input{ + Bucket: aws.String(c.Bucket), + Prefix: aws.String(prefix), + Delimiter: aws.String("/"), + ContinuationToken: continuationToken, + }) + if err != nil { + return nil, errors.Convert(err) + } + + for _, common := range output.CommonPrefixes { + if common.Prefix == nil { + continue + } + // Strip the queried prefix and the trailing slash to leave just the + // segment name. + name := strings.TrimSuffix(strings.TrimPrefix(*common.Prefix, prefix), "/") + if name != "" { + names = append(names, name) + } + } + + if output.IsTruncated == nil || !*output.IsTruncated { + break + } + continuationToken = output.NextContinuationToken + } + + sort.Strings(names) + return names, nil +} + +// CountObjects reports how many collectable objects sit under a prefix. +// +// This is what turns "did I get the path right?" into an answerable question: +// the connection test reports these counts per stream, so a wrong prefix shows +// as zero before any scope is created. +// +// Counting stops at limit to keep the check cheap; the returned bool reports +// whether more objects remain. +func (c *KiroS3Client) CountObjects(prefix string, limit int) (int, bool, errors.Error) { + if prefix != "" && !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + + count := 0 + var continuationToken *string + for { + output, err := c.S3.ListObjectsV2(&s3.ListObjectsV2Input{ + Bucket: aws.String(c.Bucket), + Prefix: aws.String(prefix), + ContinuationToken: continuationToken, + }) + if err != nil { + return 0, false, errors.Convert(err) + } + + for _, object := range output.Contents { + if object.Key == nil { + continue + } + // Same filter the collector applies, so the count reflects what + // would actually be collected rather than every object present. + if !strings.HasSuffix(*object.Key, ".csv") && !strings.HasSuffix(*object.Key, ".json.gz") { + continue + } + count++ + if limit > 0 && count >= limit { + return count, true, nil + } + } + + if output.IsTruncated == nil || !*output.IsTruncated { + break + } + continuationToken = output.NextContinuationToken + } + + return count, false, nil +} + +// GetObjectBytes downloads an object in full. +// +// Objects are small - roughly 700 bytes for a chat log, a few KB for a +// completion log, and under 1 KB for a report CSV - so streaming would add +// complexity without saving memory. +func (c *KiroS3Client) GetObjectBytes(key string) ([]byte, errors.Error) { + output, err := c.S3.GetObject(&s3.GetObjectInput{ + Bucket: aws.String(c.Bucket), + Key: aws.String(key), + }) + if err != nil { + return nil, errors.Convert(err) + } + defer output.Body.Close() + + data, readErr := io.ReadAll(output.Body) + if readErr != nil { + return nil, errors.Convert(readErr) + } + return data, nil +} diff --git a/backend/plugins/kiro/tasks/s3_client_test.go b/backend/plugins/kiro/tasks/s3_client_test.go new file mode 100644 index 00000000000..08c910fcf20 --- /dev/null +++ b/backend/plugins/kiro/tasks/s3_client_test.go @@ -0,0 +1,237 @@ +/* +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 ( + "bytes" + "errors" + "io" + "testing" + + "github.com/aws/aws-sdk-go/service/identitystore" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/incubator-devlake/plugins/kiro/models" +) + +// mockS3 records the bucket each call addressed, so tests can prove a request +// went to the right one. +type mockS3 struct { + getObjectBody string + getObjectErr error + listOutputs []*s3.ListObjectsV2Output + listCallIdx int + seenGetBuckets []string + seenListBuckets []string + seenGetKeys []string +} + +func (m *mockS3) GetObject(input *s3.GetObjectInput) (*s3.GetObjectOutput, error) { + m.seenGetBuckets = append(m.seenGetBuckets, *input.Bucket) + m.seenGetKeys = append(m.seenGetKeys, *input.Key) + if m.getObjectErr != nil { + return nil, m.getObjectErr + } + return &s3.GetObjectOutput{ + Body: io.NopCloser(bytes.NewReader([]byte(m.getObjectBody))), + }, nil +} + +func (m *mockS3) ListObjectsV2(input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) { + m.seenListBuckets = append(m.seenListBuckets, *input.Bucket) + if m.listCallIdx >= len(m.listOutputs) { + return &s3.ListObjectsV2Output{}, nil + } + out := m.listOutputs[m.listCallIdx] + m.listCallIdx++ + return out, nil +} + +// The fallback rules mean a single-bucket deployment (what real exports look +// like today) and the dual-bucket layout Kiro recommends both work without +// branching in collectors or extractors. +func TestKiroS3Clients_BucketRouting(t *testing.T) { + t.Run("single bucket routes both file kinds to the same bucket", func(t *testing.T) { + svc := &mockS3{} + clients := &KiroS3Clients{ + Report: &KiroS3Client{S3: svc, Bucket: "one-bucket"}, + PromptLog: &KiroS3Client{S3: svc, Bucket: "one-bucket"}, + } + + assert.Equal(t, "one-bucket", clients.ForFileType(models.FileTypeReport).Bucket) + assert.Equal(t, "one-bucket", clients.ForFileType(models.FileTypeChatLog).Bucket) + assert.Equal(t, "one-bucket", clients.ForFileType(models.FileTypeCompletionLog).Bucket) + // Deduplicated, so a connection test checks access once rather than twice. + assert.Equal(t, []string{"one-bucket"}, clients.Buckets()) + }) + + t.Run("separate buckets route by file type", func(t *testing.T) { + svc := &mockS3{} + clients := &KiroS3Clients{ + Report: &KiroS3Client{S3: svc, Bucket: "reports"}, + PromptLog: &KiroS3Client{S3: svc, Bucket: "logs"}, + } + + assert.Equal(t, "reports", clients.ForFileType(models.FileTypeReport).Bucket) + assert.Equal(t, "logs", clients.ForFileType(models.FileTypeChatLog).Bucket) + assert.Equal(t, "logs", clients.ForFileType(models.FileTypeCompletionLog).Bucket) + assert.Equal(t, []string{"reports", "logs"}, clients.Buckets()) + }) + + // An unrecognized file type must not silently read from the report bucket, + // where it would find nothing; log data is the larger and more likely case. + t.Run("unknown file type falls to the log bucket", func(t *testing.T) { + svc := &mockS3{} + clients := &KiroS3Clients{ + Report: &KiroS3Client{S3: svc, Bucket: "reports"}, + PromptLog: &KiroS3Client{S3: svc, Bucket: "logs"}, + } + assert.Equal(t, "logs", clients.ForFileType("something-new").Bucket) + }) +} + +func TestNewKiroS3Clients_FallbackFromConnection(t *testing.T) { + t.Run("no prompt log bucket falls back to the report bucket", func(t *testing.T) { + conn := &models.KiroConnection{KiroConn: models.KiroConn{ + Region: "us-east-1", + Bucket: "kiro-export-test", + }} + clients, err := NewKiroS3Clients(conn) + require.Nil(t, err) + assert.Equal(t, "kiro-export-test", clients.Report.Bucket) + assert.Equal(t, "kiro-export-test", clients.PromptLog.Bucket) + assert.Len(t, clients.Buckets(), 1) + }) + + t.Run("explicit prompt log bucket is used", func(t *testing.T) { + conn := &models.KiroConnection{KiroConn: models.KiroConn{ + Region: "us-east-1", + Bucket: "reports-bucket", + PromptLogBucket: "logs-bucket", + }} + clients, err := NewKiroS3Clients(conn) + require.Nil(t, err) + assert.Equal(t, "reports-bucket", clients.Report.Bucket) + assert.Equal(t, "logs-bucket", clients.PromptLog.Bucket) + assert.Len(t, clients.Buckets(), 2) + }) +} + +func TestKiroS3Client_GetObjectBytes(t *testing.T) { + t.Run("reads the body and addresses the bound bucket", func(t *testing.T) { + svc := &mockS3{getObjectBody: "hello"} + client := &KiroS3Client{S3: svc, Bucket: "my-bucket"} + + data, err := client.GetObjectBytes("some/key.csv") + require.Nil(t, err) + assert.Equal(t, "hello", string(data)) + assert.Equal(t, []string{"my-bucket"}, svc.seenGetBuckets) + assert.Equal(t, []string{"some/key.csv"}, svc.seenGetKeys) + }) + + t.Run("propagates an S3 error", func(t *testing.T) { + svc := &mockS3{getObjectErr: errors.New("access denied")} + client := &KiroS3Client{S3: svc, Bucket: "my-bucket"} + + _, err := client.GetObjectBytes("k") + assert.NotNil(t, err) + }) +} + +// mockIdentityStore lets the optional display-name path be exercised without +// AWS. +type mockIdentityStore struct { + displayName *string + err error +} + +func (m *mockIdentityStore) DescribeUser(*identitystore.DescribeUserInput) (*identitystore.DescribeUserOutput, error) { + if m.err != nil { + return nil, m.err + } + return &identitystore.DescribeUserOutput{DisplayName: m.displayName}, nil +} + +func TestKiroIdentityClient_ResolveDisplayName(t *testing.T) { + name := "Some Developer" + + t.Run("resolves a display name", func(t *testing.T) { + client := &KiroIdentityClient{IdentityStore: &mockIdentityStore{displayName: &name}, StoreId: "d-1"} + got, err := client.ResolveDisplayName("user-1") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, name, *got) + }) + + // The column exists for human readability; falling back to the raw id would + // make an unresolved value look like a resolved one. + t.Run("empty display name yields nil, not the user id", func(t *testing.T) { + empty := "" + client := &KiroIdentityClient{IdentityStore: &mockIdentityStore{displayName: &empty}, StoreId: "d-1"} + got, err := client.ResolveDisplayName("user-1") + require.NoError(t, err) + assert.Nil(t, got) + }) + + // Identity Store is optional, so an unconfigured client must be safe to + // call rather than something every caller has to nil-check. + t.Run("nil client is safe to call", func(t *testing.T) { + var client *KiroIdentityClient + got, err := client.ResolveDisplayName("user-1") + require.NoError(t, err) + assert.Nil(t, got) + }) + + t.Run("error surfaces but yields no name", func(t *testing.T) { + client := &KiroIdentityClient{IdentityStore: &mockIdentityStore{err: errors.New("throttled")}, StoreId: "d-1"} + got, err := client.ResolveDisplayName("user-1") + assert.Error(t, err) + assert.Nil(t, got) + }) +} + +func TestNewKiroIdentityClient_OptionalConfiguration(t *testing.T) { + // Missing configuration is not an error: collection works fully without + // display names because identity comes from the report's email column. + for _, tt := range []struct { + name string + conn models.KiroConn + }{ + {"neither set", models.KiroConn{}}, + {"only store id", models.KiroConn{IdentityStoreId: "d-1"}}, + {"only region", models.KiroConn{IdentityStoreRegion: "us-east-1"}}, + } { + t.Run(tt.name, func(t *testing.T) { + client, err := NewKiroIdentityClient(&models.KiroConnection{KiroConn: tt.conn}) + require.NoError(t, err) + assert.Nil(t, client) + }) + } + + t.Run("fully configured returns a client", func(t *testing.T) { + client, err := NewKiroIdentityClient(&models.KiroConnection{KiroConn: models.KiroConn{ + IdentityStoreId: "d-1234567890", + IdentityStoreRegion: "us-east-1", + }}) + require.NoError(t, err) + require.NotNil(t, client) + assert.Equal(t, "d-1234567890", client.StoreId) + }) +} diff --git a/backend/plugins/kiro/tasks/s3_file_collector.go b/backend/plugins/kiro/tasks/s3_file_collector.go new file mode 100644 index 00000000000..552292e3737 --- /dev/null +++ b/backend/plugins/kiro/tasks/s3_file_collector.go @@ -0,0 +1,171 @@ +/* +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 ( + "path" + "strings" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/s3" + + "github.com/apache/incubator-devlake/core/dal" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/plugins/kiro/models" +) + +var _ plugin.SubTaskEntryPoint = CollectKiroS3Files + +// CollectKiroS3FilesMeta discovers which S3 objects exist for a scope. +var CollectKiroS3FilesMeta = plugin.SubTaskMeta{ + Name: "collectKiroS3Files", + EntryPoint: CollectKiroS3Files, + EnabledByDefault: true, + Description: "List Kiro export objects in S3 and record them for extraction", + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, +} + +// CollectKiroS3Files lists every relevant object under the scope's prefixes and +// records the ones not seen before. +// +// Work is batched per listing page rather than per object. A single S3 page +// holds up to 1000 keys, so one SELECT and one INSERT per page replaces two +// round trips per file - at tens of thousands of objects a day that is the +// difference between dozens of queries and tens of thousands. +func CollectKiroS3Files(taskCtx plugin.SubTaskContext) errors.Error { + data := taskCtx.GetData().(*KiroTaskData) + db := taskCtx.GetDal() + logger := taskCtx.GetLogger() + + taskCtx.SetProgress(0, -1) + + for _, spec := range data.Prefixes { + client := data.S3Clients.ForFileType(spec.FileType) + prefix := spec.Prefix + if prefix != "" && !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + logger.Info("scanning s3://%s/%s for %s files", client.Bucket, prefix, spec.FileType) + + var continuationToken *string + for { + output, listErr := client.S3.ListObjectsV2(&s3.ListObjectsV2Input{ + Bucket: aws.String(client.Bucket), + Prefix: aws.String(prefix), + ContinuationToken: continuationToken, + }) + if listErr != nil { + return errors.Convert(listErr) + } + + candidates := collectCandidates(output, client.Bucket, spec, data.Options) + inserted, saveErr := saveNewFileMeta(db, data.Options.ConnectionId, candidates) + if saveErr != nil { + return saveErr + } + taskCtx.IncProgress(inserted) + + // IsTruncated is a pointer; dereferencing it unguarded panics on an + // empty response. + if output.IsTruncated == nil || !*output.IsTruncated { + break + } + continuationToken = output.NextContinuationToken + } + } + + return nil +} + +// collectCandidates turns one listing page into file metadata rows. +// +// Only .csv and .json.gz are kept. That filter also excludes the small +// extension-less objects AWS writes at the KiroLogs root as permission probes. +func collectCandidates(output *s3.ListObjectsV2Output, bucket string, spec PrefixSpec, options *KiroOptions) []*models.KiroS3FileMeta { + candidates := make([]*models.KiroS3FileMeta, 0, len(output.Contents)) + for _, object := range output.Contents { + if object.Key == nil { + continue + } + key := *object.Key + if !strings.HasSuffix(key, ".csv") && !strings.HasSuffix(key, ".json.gz") { + continue + } + candidates = append(candidates, &models.KiroS3FileMeta{ + ConnectionId: options.ConnectionId, + S3Path: key, + // Basename only. The full key lives in S3Path, which is sized for + // it; putting a full key here would eventually overflow the column. + FileName: path.Base(key), + Bucket: bucket, + ScopeId: options.ScopeId, + FileType: spec.FileType, + Processed: false, + }) + } + return candidates +} + +// saveNewFileMeta inserts the rows that are not already recorded, returning how +// many were added. +// +// The existence check queries by (connection_id, s3_path), which is exactly the +// primary key. Querying on an unindexed column here would turn each page into a +// full table scan and the task would never finish at scale. +func saveNewFileMeta(db dal.Dal, connectionId uint64, candidates []*models.KiroS3FileMeta) (int, errors.Error) { + if len(candidates) == 0 { + return 0, nil + } + + paths := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + paths = append(paths, candidate.S3Path) + } + + var existingRows []models.KiroS3FileMeta + err := db.All(&existingRows, + dal.Select("s3_path"), + dal.From(&models.KiroS3FileMeta{}), + dal.Where("connection_id = ? AND s3_path IN ?", connectionId, paths), + ) + if err != nil { + return 0, errors.Default.Wrap(err, "failed to query existing kiro file metadata") + } + + existing := make(map[string]struct{}, len(existingRows)) + for _, row := range existingRows { + existing[row.S3Path] = struct{}{} + } + + fresh := make([]*models.KiroS3FileMeta, 0, len(candidates)) + for _, candidate := range candidates { + if _, seen := existing[candidate.S3Path]; seen { + continue + } + fresh = append(fresh, candidate) + } + if len(fresh) == 0 { + return 0, nil + } + + if err := db.Create(fresh); err != nil { + return 0, errors.Default.Wrap(err, "failed to record kiro file metadata") + } + return len(fresh), nil +} diff --git a/backend/plugins/kiro/tasks/s3_file_collector_test.go b/backend/plugins/kiro/tasks/s3_file_collector_test.go new file mode 100644 index 00000000000..e45b26044fb --- /dev/null +++ b/backend/plugins/kiro/tasks/s3_file_collector_test.go @@ -0,0 +1,215 @@ +/* +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 ( + "os" + "regexp" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/incubator-devlake/plugins/kiro/models" +) + +func listOutput(truncated bool, keys ...string) *s3.ListObjectsV2Output { + contents := make([]*s3.Object, 0, len(keys)) + for _, k := range keys { + key := k + contents = append(contents, &s3.Object{Key: &key}) + } + return &s3.ListObjectsV2Output{ + Contents: contents, + IsTruncated: aws.Bool(truncated), + } +} + +func TestCollectCandidates_FileFiltering(t *testing.T) { + spec := PrefixSpec{FileType: models.FileTypeChatLog} + options := &KiroOptions{ConnectionId: 1, ScopeId: "s1"} + + t.Run("keeps csv and json.gz only", func(t *testing.T) { + output := listOutput(false, + "p/report.csv", + "p/log.json.gz", + // AWS writes small extension-less objects at the KiroLogs root as + // permission probes; they must not enter the work queue. + "p/26404955-bf00-40d3-b713-43d18edf0638", + "p/notes.txt", + "p/archive.zip", + ) + candidates := collectCandidates(output, "bkt", spec, options) + require.Len(t, candidates, 2) + assert.Equal(t, "p/report.csv", candidates[0].S3Path) + assert.Equal(t, "p/log.json.gz", candidates[1].S3Path) + }) + + t.Run("stores basename separately from the full key", func(t *testing.T) { + key := "logging/AWSLogs/123456789012/KiroLogs/GenerateAssistantResponse/us-east-1/2026/07/27/23/" + + "123456789012_GenerateAssistantResponse_202607272303_3tbIeIrGJNDFbfVx.json.gz" + candidates := collectCandidates(listOutput(false, key), "bkt", spec, options) + require.Len(t, candidates, 1) + + // The full key goes in S3Path, which is sized for it. FileName holds + // only the basename - a full key there would eventually overflow. + assert.Equal(t, key, candidates[0].S3Path) + assert.Equal(t, "123456789012_GenerateAssistantResponse_202607272303_3tbIeIrGJNDFbfVx.json.gz", + candidates[0].FileName) + assert.Less(t, len(candidates[0].FileName), 255) + }) + + t.Run("records bucket, scope and file type", func(t *testing.T) { + candidates := collectCandidates(listOutput(false, "p/a.csv"), "my-bucket", spec, options) + require.Len(t, candidates, 1) + assert.Equal(t, "my-bucket", candidates[0].Bucket) + assert.Equal(t, "s1", candidates[0].ScopeId) + assert.Equal(t, models.FileTypeChatLog, candidates[0].FileType) + assert.Equal(t, uint64(1), candidates[0].ConnectionId) + assert.False(t, candidates[0].Processed) + }) + + t.Run("nil key is skipped", func(t *testing.T) { + output := &s3.ListObjectsV2Output{ + Contents: []*s3.Object{{Key: nil}, {Key: aws.String("p/a.csv")}}, + IsTruncated: aws.Bool(false), + } + candidates := collectCandidates(output, "bkt", spec, options) + assert.Len(t, candidates, 1) + }) + + t.Run("empty page yields nothing", func(t *testing.T) { + candidates := collectCandidates(listOutput(false), "bkt", spec, options) + assert.Empty(t, candidates) + }) +} + +func TestBuildPrefixes(t *testing.T) { + // Paths verified against real exports. + t.Run("single bucket layout", func(t *testing.T) { + conn := &models.KiroConnection{KiroConn: models.KiroConn{ + Region: "us-east-1", + Bucket: "kiro-export-test", + ReportPrefix: "user-report", + PromptLogPrefix: "logging", + }} + prefixes := BuildPrefixes(conn, "123456789012", "2026/07") + + require.Len(t, prefixes, 3) + assert.Equal(t, + "user-report/AWSLogs/123456789012/KiroLogs/user_report/us-east-1/2026/07", + prefixes[0].Prefix) + assert.Equal(t, models.FileTypeReport, prefixes[0].FileType) + assert.Equal(t, + "logging/AWSLogs/123456789012/KiroLogs/GenerateAssistantResponse/us-east-1/2026/07", + prefixes[1].Prefix) + assert.Equal(t, models.FileTypeChatLog, prefixes[1].FileType) + assert.Equal(t, + "logging/AWSLogs/123456789012/KiroLogs/GenerateCompletions/us-east-1/2026/07", + prefixes[2].Prefix) + assert.Equal(t, models.FileTypeCompletionLog, prefixes[2].FileType) + + for _, p := range prefixes { + assert.Equal(t, "kiro-export-test", p.Bucket) + } + }) + + t.Run("defaults apply when prefixes are unset", func(t *testing.T) { + conn := &models.KiroConnection{KiroConn: models.KiroConn{ + Region: "us-east-1", + Bucket: "b", + }} + prefixes := BuildPrefixes(conn, "acct", "2026/07") + assert.Contains(t, prefixes[0].Prefix, "user-report/AWSLogs/acct/KiroLogs/user_report") + assert.Contains(t, prefixes[1].Prefix, "logging/AWSLogs/acct/KiroLogs/GenerateAssistantResponse") + }) + + t.Run("separate buckets are assigned per file type", func(t *testing.T) { + conn := &models.KiroConnection{KiroConn: models.KiroConn{ + Region: "us-east-1", + Bucket: "reports", + PromptLogBucket: "logs", + }} + prefixes := BuildPrefixes(conn, "acct", "2026/07") + assert.Equal(t, "reports", prefixes[0].Bucket) + assert.Equal(t, "logs", prefixes[1].Bucket) + assert.Equal(t, "logs", prefixes[2].Bucket) + }) + + // A nil month widens the scope to the whole year, which is how a year-long + // backfill is expressed. + t.Run("year-only time path", func(t *testing.T) { + conn := &models.KiroConnection{KiroConn: models.KiroConn{Region: "us-east-1", Bucket: "b"}} + prefixes := BuildPrefixes(conn, "acct", "2026") + assert.True(t, regexp.MustCompile(`/us-east-1/2026$`).MatchString(prefixes[0].Prefix)) + }) +} + +func TestWorkerCount(t *testing.T) { + assert.Equal(t, DefaultWorkerCount, (&KiroTaskData{Options: &KiroOptions{}}).WorkerCount()) + assert.Equal(t, 5, (&KiroTaskData{Options: &KiroOptions{WorkerCount: 5}}).WorkerCount()) + // A zero or negative override is ignored rather than disabling concurrency. + assert.Equal(t, DefaultWorkerCount, (&KiroTaskData{Options: &KiroOptions{WorkerCount: -1}}).WorkerCount()) + assert.Equal(t, DefaultWorkerCount, (&KiroTaskData{}).WorkerCount()) +} + +// This guards the predecessor defect that motivated the primary key choice: it queried +// its cursor table by s3_path while keying it on file_name, so the lookup falls +// back to scanning every row for the connection and collection never finishes at +// scale. The failure mode is a task that hangs rather than an error, so it is +// worth asserting structurally instead of hoping a reviewer notices. +func TestFileMetaQueriesMatchPrimaryKey(t *testing.T) { + pkColumns := primaryKeyColumns(t, "s3_file_meta.go") + require.Equal(t, []string{"ConnectionId", "S3Path"}, pkColumns, + "the cursor table must be keyed on the connection and the full object path") + + source, err := os.ReadFile("s3_file_collector.go") + require.NoError(t, err) + + whereClauses := regexp.MustCompile(`dal\.Where\(\s*"([^"]+)"`).FindAllStringSubmatch(string(source), -1) + require.NotEmpty(t, whereClauses, "expected at least one filtered query") + + for _, clause := range whereClauses { + condition := clause[1] + assert.Contains(t, condition, "connection_id", + "every cursor query must filter on connection_id, the first key column") + assert.Contains(t, condition, "s3_path", + "every cursor query must filter on s3_path, the second key column") + assert.NotContains(t, condition, "file_name", + "file_name is not indexed and must never appear in a lookup") + } +} + +// primaryKeyColumns extracts the fields tagged as primary keys from a model +// file, in declaration order - which is also the order of the composite index. +func primaryKeyColumns(t *testing.T, modelFile string) []string { + t.Helper() + source, err := os.ReadFile("../models/" + modelFile) + require.NoError(t, err) + + fieldRe := regexp.MustCompile(`(?m)^\s*([A-Z][A-Za-z0-9]*)\s+\S+\s+` + "`" + `[^` + "`" + `]*primaryKey[^` + "`" + `]*` + "`") + matches := fieldRe.FindAllStringSubmatch(string(source), -1) + + columns := make([]string, 0, len(matches)) + for _, m := range matches { + columns = append(columns, m[1]) + } + return columns +} diff --git a/backend/plugins/kiro/tasks/task_data.go b/backend/plugins/kiro/tasks/task_data.go new file mode 100644 index 00000000000..c8b504f1d18 --- /dev/null +++ b/backend/plugins/kiro/tasks/task_data.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 ( + "fmt" + + "github.com/apache/incubator-devlake/plugins/kiro/models" +) + +// DefaultWorkerCount is how many objects are fetched concurrently. +// +// Collection is bound by request count, not bandwidth: a single user can +// produce ~600 log objects on a busy day, each under a kilobyte. S3 sustains +// thousands of GETs per second per prefix, so 20 is well within limits while +// cutting a peak day from tens of minutes to a couple. +const DefaultWorkerCount = 20 + +// KiroOptions are the blueprint-supplied task options. +type KiroOptions struct { + ConnectionId uint64 `json:"connectionId"` + ScopeId string `json:"scopeId"` + AccountId string `json:"accountId"` + Year int `json:"year"` + Month *int `json:"month"` + // WorkerCount overrides DefaultWorkerCount when set above zero. + WorkerCount int `json:"workerCount"` +} + +// PrefixSpec is one S3 location to scan, along with the kind of file found +// there and which bucket holds it. +type PrefixSpec struct { + Bucket string + Prefix string + FileType string +} + +// KiroTaskData is shared by every subtask in a run. +type KiroTaskData struct { + Options *KiroOptions + Connection *models.KiroConnection + S3Clients *KiroS3Clients + IdentityClient *KiroIdentityClient + // Prefixes are the locations to scan, precomputed so the collector does not + // re-derive paths. + Prefixes []PrefixSpec +} + +// WorkerCount returns the effective concurrency for this run. +func (d *KiroTaskData) WorkerCount() int { + if d.Options != nil && d.Options.WorkerCount > 0 { + return d.Options.WorkerCount + } + return DefaultWorkerCount +} + +// BuildPrefixes derives the three S3 locations a scope covers. +// +// Layout confirmed against real exports: +// +// {bucket}/{reportPrefix}/AWSLogs/{acct}/KiroLogs/user_report/{region}/{y}/{m} +// {bucket}/{logPrefix}/AWSLogs/{acct}/KiroLogs/GenerateAssistantResponse/{region}/{y}/{m} +// {bucket}/{logPrefix}/AWSLogs/{acct}/KiroLogs/GenerateCompletions/{region}/{y}/{m} +// +// The report path's hour segment is always 00 (reports are written at 02:00 +// UTC) while log paths carry a real hour, but neither is included here: the +// prefix stops at the month so a scope lists the whole period in one sweep. +func BuildPrefixes(connection *models.KiroConnection, accountId string, timePath string) []PrefixSpec { + region := connection.Region + reportBase := fmt.Sprintf("%s/AWSLogs/%s/KiroLogs", connection.GetReportPrefix(), accountId) + logBase := fmt.Sprintf("%s/AWSLogs/%s/KiroLogs", connection.GetPromptLogPrefix(), accountId) + + return []PrefixSpec{ + { + Bucket: connection.Bucket, + Prefix: fmt.Sprintf("%s/user_report/%s/%s", reportBase, region, timePath), + FileType: models.FileTypeReport, + }, + { + Bucket: connection.GetPromptLogBucket(), + Prefix: fmt.Sprintf("%s/GenerateAssistantResponse/%s/%s", logBase, region, timePath), + FileType: models.FileTypeChatLog, + }, + { + Bucket: connection.GetPromptLogBucket(), + Prefix: fmt.Sprintf("%s/GenerateCompletions/%s/%s", logBase, region, timePath), + FileType: models.FileTypeCompletionLog, + }, + } +} diff --git a/backend/plugins/kiro/tasks/testdata/logs/chat_01_empty_prompt.json.gz b/backend/plugins/kiro/tasks/testdata/logs/chat_01_empty_prompt.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..a713fdaf95d50916286ecbd51876db42b1a8f3bb GIT binary patch literal 274 zcmV+t0qy=DiwFP!00002|K(CmZ-gKa{VN;JWrLt1-aPcsr0rpAFWq<`GFp>WC}7jZ z_}>e(@vmgttw#uRMa9&Uk6Ikh?a&B;5PW}YW=*hwTCq(iwgYzME zLB{IQ#3w>`2h_exU)!PY!PegfqWh_Oeytv3aCTrHRP-sFP?lynF9bOzSK!U0(G)j& zLoD~h2rpiaCU&5N=9q9yc}-bKSV_f^P@WPZsCbL%qgHU)ziTgYR)Dpc9nR1if`84o zlWjoxJ0*5J+bbza^YuTm$sKllf(|T9?Y_G0ewq-n`;F`Z2`G`nSnHL}MUqcIXKAA0?h}mi zQGm`%(~Xs>G|tS_n$^<~^RKqNC41052G7dDi|`}p08)P5(dCyHIasp}!JTFZdNH(f zg!{9cqO&%_<{ zKoXDt$GiJ2Z8-Y_?Bo|=zz5}X@E@<4(>fK_?Tr<^i+rk9%O$AkcLwGmejGo&j2{eG zXi)dk{FBHg4s|`|2Y6)f1XTkygTGhzV%+mp49~fUGs8eI@epQ+uqkmuTz+iCd`f(m z+Vs_!$Ek#My^xv4GR{s2)IeS$Uz002AM!wvud literal 0 HcmV?d00001 diff --git a/backend/plugins/kiro/tasks/testdata/logs/chat_03_two_records.json.gz b/backend/plugins/kiro/tasks/testdata/logs/chat_03_two_records.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..37faba6e2e819e16b754275e367813a6c3fcb5ca GIT binary patch literal 315 zcmV-B0mS|viwFP!00002|J6}FPXjRw{VP>wQstcFlDn+~0}?QlvQTx%CB9Or_d$|F zpsN3!ORr^MK@1?_!OF9r?fCgXJlf8?K+4(rK(^STHxU;h>_RkcTw>_EHsJLuCh#&~ zh$HntdfzpDdJ=LqBlg+EmEUc*=vVJOrtsb3{&8`el0!g$<5JAg47iZF(gmAmXQL-4 zlG7J>u~Z&+4L(HE^r-_d$SG44)CzdXLh?Z(DIW3%URyf0~&c|DC Njvt_Pmxgcy000cknU(+m literal 0 HcmV?d00001 diff --git a/backend/plugins/kiro/tasks/testdata/logs/chat_04_no_model_id.json.gz b/backend/plugins/kiro/tasks/testdata/logs/chat_04_no_model_id.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..21551bd3f681b3fe9d4e21259b933bbe00cc740f GIT binary patch literal 368 zcmV-$0gwJ4iwFP!00002|AkUbPs1<_{VOPEIwk#B)82aEfV3erhL9LNWUW^!+N31u zU`+k*H0jFFrfEJ{vEQ@f_+^hIWJ2Z&#W$-xDu9Dz8WN>Sr5V=?P>tXU?zUip&lZ&K zse9B&Q8(s@R>LvI>G!F<@ew5F;3Yd2x#<~n1pV}KVQ}p>bbEucccz!JEDDgzT?6JR z^W@<s&G}dJe zPny+@>EL2_>R{qve~D>KaE$3BoMKD^9Qv=Q>-pCd83HQ_JEd54B=Ac>5G_#E}!M7ll-GzPnxC*WN*?0zjNi%UKa#+9yB=m@#(tfVA zhg91%71Vu-$=wUAXC?nomNGKr@WqA&Q_uAx8bwa-h0O8U+I1);%*kD{@tB*DfO)9v O((V@=W2}$H0ssJYPP&}{ literal 0 HcmV?d00001 diff --git a/backend/plugins/kiro/tasks/testdata/logs/completion_01_non_empty.json.gz b/backend/plugins/kiro/tasks/testdata/logs/completion_01_non_empty.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..c25e2a154b18edad81d87cb8a053348537761bd4 GIT binary patch literal 956 zcmV;t14H~DiwFP!00002|Fv05Z`&{o{wsygOCehC+=gO@U3S=ZTE|1$s9lCUy2Krd z75(qClcse#&_Q;bOD8TZU$_S?@ml z4(sM~_;nw4O)KM7pMpO^YIyC=5Tr$&wiU~TccoZPS?+&vp^hJPbk!R-rxmfUZ&1F%2 zj6h6Rn8ZoK5evlWHd&eG;jdnT*e~%xU( zxPKph(&g;bKv6k%PmBe7=^V2xalOmf8RITN#Lb9@awTl5;}y%%WN~L#3-9n>x7C9l z&w3LCaRdm!T;*h_BotNFUkQ!nvkon9L?|c60R*PFK%50Np%8+iqcpzBzd(~8z-6GP zZ61*TEKDmnG--q?u{cSI4vpiGEEbr`eJ((+jU&B2tqob`B{H1UM^2AM#_+2n$edb` z9!uh^1ioe20Yw^FBEjVf5o8xsXnQ?EGH+hNlp_?6Wh#0NA&*@1RYF7+m%F{rkgyE# ziY5eyCAj)cAK~R-HZt!E*6_hLG}y*qE&xYYPvbKTw=nvt2bTRjgSO2hBtpr ztN3Vg(9_ksvueAz{n0CmiJzkySX0Zxin%U~E)6mA> zOoW_({|a)d{8YPhC5;wBDEL-AwetMo^zN>G4*S#T>sMxXq}>61B~2l_OX&@Y6SK#X zNJt6`l%EbY7%Q__uEiq{1y1+pJ_j)61K^zS@>pS)W{3X?4oMNj9D+4sGV{Vtl%1=| e)oi9GUe0gCjdWMFRn^lIKK%tEg}4TV7ytlO$lWCX literal 0 HcmV?d00001 diff --git a/backend/plugins/kiro/tasks/testdata/logs/completion_02_empty.json.gz b/backend/plugins/kiro/tasks/testdata/logs/completion_02_empty.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..a914b79e55feb627b15fe84fb49d08b06afe30b3 GIT binary patch literal 909 zcmV;819JQyiwFP!00002|Fu|4lhZH^{wq5<(;dri_r~mS;KBhovFK3JbScxOrA?U~ zcKY8*+AW*4qQogf4tYqjtxpeI(Nnf7^386(&!#u`PuW9J7Q0zh+-=s|RZ%TB<^J8T zqO3j^KM%#es)e4iRk5hjHs9I(*YdtangTvPMkI+5LDe$nD`MzaGWxWC2ez&k znMm4W_bLIq$6sDzM6zquuJzC2idhzPOfdZu4+4bv+hdZ3VH6-K($xGv7YklWE{f#E z12J7=FHYi)SRf9+p~#UPhK^Gq&A9O>z)mu262+KA>7%eu#PmMlWQ?l>5f>u?#!teQIzh7>LpFDIw+J5p^;kX6 z6Ig44Acg<|m^wKbst84otFMH?@vC-iZ$v01#sLKOae){MB%u(3rlSmAWnZAd58xBf z(=v}p0FDeRI2UPzs&I6Wk~s_xLvlDmRql5IT4fxm^=WRXaaJP3Nq(f%Xk-k(JAz8d z1r>0NpS8eiwjI!61dGpX9P#P!gW$UB)8EahSL`8)x!VU&h2&K)`%>YtK!3KT{P@vuR;!wG*cZF^^G1F?sY^O-tn)$K!t*sb-E?#Fm(R=9Ok6G3#iwev-ZnGT z?za=6CJ;V@n(8n${?==wWIf)0$&R%=-<#fV>-XZ+oX%gEO-CCW?C0L~S<_$zEKIH@ j_0vvtsF(EfSw6|X8R?x3I$x;lc*Nr$TRZ#W85aNm^hUs! literal 0 HcmV?d00001 diff --git a/backend/plugins/kiro/tasks/user_report_extractor.go b/backend/plugins/kiro/tasks/user_report_extractor.go new file mode 100644 index 00000000000..ddef74a1cd5 --- /dev/null +++ b/backend/plugins/kiro/tasks/user_report_extractor.go @@ -0,0 +1,63 @@ +/* +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 ( + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/plugins/kiro/models" +) + +var _ plugin.SubTaskEntryPoint = ExtractKiroUserReport + +var ExtractKiroUserReportMeta = plugin.SubTaskMeta{ + Name: "extractKiroUserReport", + EntryPoint: ExtractKiroUserReport, + EnabledByDefault: true, + Description: "Extract daily per-user activity from Kiro report CSVs", + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Dependencies: []*plugin.SubTaskMeta{&CollectKiroS3FilesMeta}, +} + +// ExtractKiroUserReport loads the report CSVs discovered for this scope. +// +// Reports are written once per day per client type, so there are only a few +// hundred per year - the concurrency that matters for logs is irrelevant here, +// but reusing extractFiles keeps the retry and bookkeeping behaviour identical +// across all three streams. +func ExtractKiroUserReport(taskCtx plugin.SubTaskContext) errors.Error { + return extractFiles(taskCtx, models.FileTypeReport, parseUserReportRows) +} + +// parseUserReportRows adapts ParseUserReport to the extractor's batch interface. +// +// Both tables come from one parse because the per-model counts are columns of the +// same CSV row; splitting them into two passes would mean reading every file +// twice. They are returned as two batches rather than one mixed slice because +// GORM resolves the target table from the slice's element type. +func parseUserReportRows(data []byte, connectionId uint64, scopeId string) ([]rowBatch, errors.Error) { + reports, modelMessages, err := ParseUserReport(data, connectionId, scopeId) + if err != nil { + return nil, err + } + + return []rowBatch{ + {rows: reports, count: len(reports)}, + {rows: modelMessages, count: len(modelMessages)}, + }, nil +} diff --git a/backend/plugins/schema_e2e/migration_schema_test.go b/backend/plugins/schema_e2e/migration_schema_test.go index 0fbc02893bc..5cdf115f960 100644 --- a/backend/plugins/schema_e2e/migration_schema_test.go +++ b/backend/plugins/schema_e2e/migration_schema_test.go @@ -70,6 +70,7 @@ import ( issueTrace "github.com/apache/incubator-devlake/plugins/issue_trace/impl" jenkins "github.com/apache/incubator-devlake/plugins/jenkins/impl" jira "github.com/apache/incubator-devlake/plugins/jira/impl" + kiro "github.com/apache/incubator-devlake/plugins/kiro/impl" linear "github.com/apache/incubator-devlake/plugins/linear/impl" linker "github.com/apache/incubator-devlake/plugins/linker/impl" opsgenie "github.com/apache/incubator-devlake/plugins/opsgenie/impl" @@ -121,6 +122,7 @@ func allGoPlugins() []plugin.PluginMeta { issueTrace.IssueTrace{}, jenkins.Jenkins{}, jira.Jira{}, + kiro.Kiro{}, linear.Linear{}, linker.Linker{}, opsgenie.Opsgenie{}, diff --git a/backend/plugins/table_info_test.go b/backend/plugins/table_info_test.go index c3262153dfc..f4182fe9d89 100644 --- a/backend/plugins/table_info_test.go +++ b/backend/plugins/table_info_test.go @@ -46,12 +46,12 @@ import ( issueTrace "github.com/apache/incubator-devlake/plugins/issue_trace/impl" jenkins "github.com/apache/incubator-devlake/plugins/jenkins/impl" jira "github.com/apache/incubator-devlake/plugins/jira/impl" + kiro "github.com/apache/incubator-devlake/plugins/kiro/impl" linear "github.com/apache/incubator-devlake/plugins/linear/impl" linker "github.com/apache/incubator-devlake/plugins/linker/impl" opsgenie "github.com/apache/incubator-devlake/plugins/opsgenie/impl" org "github.com/apache/incubator-devlake/plugins/org/impl" pagerduty "github.com/apache/incubator-devlake/plugins/pagerduty/impl" - q_dev "github.com/apache/incubator-devlake/plugins/q_dev/impl" refdiff "github.com/apache/incubator-devlake/plugins/refdiff/impl" rootly "github.com/apache/incubator-devlake/plugins/rootly/impl" slack "github.com/apache/incubator-devlake/plugins/slack/impl" @@ -114,7 +114,7 @@ func Test_GetPluginTablesInfo(t *testing.T) { checker.FeedIn("opsgenie/models", opsgenie.Opsgenie{}.GetTablesInfo) checker.FeedIn("linker/models", linker.Linker{}.GetTablesInfo) checker.FeedIn("issue_trace/models", issueTrace.IssueTrace{}.GetTablesInfo) - checker.FeedIn("q_dev/models", q_dev.QDev{}.GetTablesInfo) + checker.FeedIn("kiro/models", kiro.Kiro{}.GetTablesInfo) checker.FeedIn("gh-copilot/models", copilot.GhCopilot{}.GetTablesInfo) err := checker.Verify() if err != nil { From 0c61692294beaee675ea29c122bd9ac8d241d58c Mon Sep 17 00:00:00 2001 From: warren Date: Thu, 27 Aug 2026 10:37:56 +0800 Subject: [PATCH 2/4] feat(config-ui): add Kiro connection support Signed-off-by: warren --- config-ui/src/plugins/register/index.ts | 2 + .../src/plugins/register/kiro/assets/icon.svg | 62 +++++ .../src/plugins/register/kiro/config.tsx | 104 ++++++++ config-ui/src/plugins/register/kiro/index.ts | 19 ++ e2e/kiro-full-flow.spec.ts | 252 ++++++++++++++++++ 5 files changed, 439 insertions(+) create mode 100644 config-ui/src/plugins/register/kiro/assets/icon.svg create mode 100644 config-ui/src/plugins/register/kiro/config.tsx create mode 100644 config-ui/src/plugins/register/kiro/index.ts create mode 100644 e2e/kiro-full-flow.spec.ts diff --git a/config-ui/src/plugins/register/index.ts b/config-ui/src/plugins/register/index.ts index 259d01bb0f1..441cda5cb2f 100644 --- a/config-ui/src/plugins/register/index.ts +++ b/config-ui/src/plugins/register/index.ts @@ -33,6 +33,7 @@ import { GitLabConfig } from './gitlab'; import { IncidentioConfig } from './incidentio'; import { JenkinsConfig } from './jenkins'; import { JiraConfig } from './jira'; +import { KiroConfig } from './kiro'; import { LinearConfig } from './linear'; import { PagerDutyConfig } from './pagerduty'; import { RootlyConfig } from './rootly'; @@ -64,6 +65,7 @@ export const pluginConfigs: IPluginConfig[] = [ IncidentioConfig, JenkinsConfig, JiraConfig, + KiroConfig, LinearConfig, PagerDutyConfig, RootlyConfig, diff --git a/config-ui/src/plugins/register/kiro/assets/icon.svg b/config-ui/src/plugins/register/kiro/assets/icon.svg new file mode 100644 index 00000000000..503114f140a --- /dev/null +++ b/config-ui/src/plugins/register/kiro/assets/icon.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/config-ui/src/plugins/register/kiro/config.tsx b/config-ui/src/plugins/register/kiro/config.tsx new file mode 100644 index 00000000000..d848e8a852e --- /dev/null +++ b/config-ui/src/plugins/register/kiro/config.tsx @@ -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. + * + */ + +import { IPluginConfig } from '@/types'; + +import Icon from './assets/icon.svg?react'; + +export const KiroConfig: IPluginConfig = { + plugin: 'kiro', + name: 'Kiro', + icon: ({ color }) => , + sort: 12, + connection: { + docLink: 'https://kiro.dev/docs/enterprise/monitor-and-track/user-activity/', + initialValues: { + name: '', + region: 'us-east-1', + bucket: '', + reportPrefix: 'user-report', + promptLogBucket: '', + promptLogPrefix: 'logging', + identityStoreId: '', + identityStoreRegion: '', + }, + fields: [ + 'name', + { + key: 'region', + label: 'AWS Region', + subLabel: + 'The region where your Kiro profile was installed. The exports live under this region in the S3 path, so it must match exactly.', + }, + { + key: 'bucket', + label: 'S3 Bucket', + subLabel: 'Bucket holding the daily user activity report CSVs.', + }, + { + key: 'reportPrefix', + label: 'Report Prefix', + subLabel: 'Prefix within the bucket, before AWSLogs/. Leave as user-report unless you configured another.', + defaultValue: 'user-report', + }, + { + key: 'promptLogBucket', + label: 'Prompt Log Bucket (optional)', + subLabel: + 'Only needed if interaction logs go to a different bucket, which Kiro recommends. Leave empty to reuse the bucket above.', + }, + { + key: 'promptLogPrefix', + label: 'Prompt Log Prefix', + subLabel: 'Prefix for the interaction logs. Leave as logging unless you configured another.', + defaultValue: 'logging', + }, + { + key: 'accessKeyId', + label: 'AWS Access Key ID', + }, + { + key: 'secretAccessKey', + label: 'AWS Secret Access Key', + }, + { + key: 'identityStoreId', + label: 'IAM Identity Center Store ID (optional)', + subLabel: + 'Only resolves display names. User identity comes from the report’s User_Email column, so collection works without this. If set, the region below is required too.', + }, + { + key: 'identityStoreRegion', + label: 'IAM Identity Center Region (optional)', + subLabel: 'May differ from the S3 region. Required if a store ID is set.', + }, + ], + }, + dataScope: { + // No custom render: the default picker calls the plugin's remote-scopes + // endpoint, which browses the export layout in S3 as accounts -> years -> + // months and lists only periods that actually hold data. Hand-entering a + // prefix cannot be verified from the outcome, because a typo and an empty + // month both produce a run that succeeds and collects nothing. + title: 'Accounts & Periods', + }, + scopeConfig: { + entities: ['CROSS'], + transformation: {}, + }, +}; diff --git a/config-ui/src/plugins/register/kiro/index.ts b/config-ui/src/plugins/register/kiro/index.ts new file mode 100644 index 00000000000..de415db39ab --- /dev/null +++ b/config-ui/src/plugins/register/kiro/index.ts @@ -0,0 +1,19 @@ +/* + * 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. + * + */ + +export * from './config'; diff --git a/e2e/kiro-full-flow.spec.ts b/e2e/kiro-full-flow.spec.ts new file mode 100644 index 00000000000..88234a18844 --- /dev/null +++ b/e2e/kiro-full-flow.spec.ts @@ -0,0 +1,252 @@ +/* +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. +*/ + +import { test, expect, request, Page } from '@playwright/test'; +import * as path from 'path'; +import * as fs from 'fs'; + +const API = 'http://localhost:8080'; +const UI = 'http://localhost:4000'; +const GRAFANA = 'http://localhost:3002'; +const SCREENSHOT_DIR = path.join(__dirname, 'screenshots'); + +// The full-flow test needs an existing Kiro connection with valid credentials. +// Keep environment-specific ids out of the repository. +const EXISTING_CONNECTION_ID = Number(process.env.KIRO_CONNECTION_ID || 0); +const KIRO_ACCOUNT_ID = process.env.KIRO_ACCOUNT_ID || ''; + +const state: { + connectionId: number; + scopeId: string; + blueprintId: number; + pipelineId: number; +} = { connectionId: EXISTING_CONNECTION_ID, scopeId: '', blueprintId: 0, pipelineId: 0 }; + +fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); + +async function grafanaLogin(page: Page) { + await page.goto(`${GRAFANA}/grafana/login`); + await page.waitForLoadState('networkidle'); + if (page.url().includes('/login')) { + await page.locator('input[name="user"]').fill('admin'); + await page.locator('input[name="password"]').fill('admin'); + await page.locator('button[type="submit"]').click(); + await page.waitForTimeout(2000); + // Handle "change password" prompt if shown + const skipBtn = page.locator('a:has-text("Skip")'); + if (await skipBtn.isVisible({ timeout: 2000 }).catch(() => false)) { + await skipBtn.click(); + } + await page.waitForTimeout(1000); + } +} + +async function openGrafanaDashboard(page: Page, uid: string, screenshotPath: string) { + await grafanaLogin(page); + await page.goto(`${GRAFANA}/grafana/d/${uid}?orgId=1&from=now-90d&to=now`); + + // Wait for first panel data to load + try { + await page.waitForResponse( + (resp) => resp.url().includes('/api/ds/query') && resp.status() === 200, + { timeout: 30000 } + ); + } catch { /* some dashboards may not fire queries immediately */ } + + // Wait for rendering to settle + await page.waitForTimeout(5000); + + // Take viewport screenshot (top section) + await page.screenshot({ path: screenshotPath.replace('.png', '-top.png') }); + + // Scroll down and take more sections + const scrollHeight = await page.evaluate(() => document.body.scrollHeight); + let section = 1; + for (let y = 900; y < scrollHeight; y += 900) { + await page.evaluate((scrollY) => window.scrollTo(0, scrollY), y); + await page.waitForTimeout(3000); + section++; + await page.screenshot({ path: screenshotPath.replace('.png', `-section${section}.png`) }); + } + + // Also take full page screenshot + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(2000); + await page.screenshot({ path: screenshotPath, fullPage: true }); +} + +test.describe.serial('Kiro Plugin Full Flow', () => { + + test('Step 1: Verify Existing Connection via API', async () => { + expect(EXISTING_CONNECTION_ID, 'set KIRO_CONNECTION_ID').toBeGreaterThan(0); + expect(KIRO_ACCOUNT_ID, 'set KIRO_ACCOUNT_ID').not.toBe(''); + const api = await request.newContext({ baseURL: API }); + + const resp = await api.get(`/plugins/kiro/connections/${state.connectionId}`); + expect(resp.ok()).toBeTruthy(); + const conn = await resp.json(); + console.log(`Using connection: id=${conn.id}, name=${conn.name}, bucket=${conn.bucket}`); + + const testResp = await api.post(`/plugins/kiro/connections/${state.connectionId}/test`); + const testBody = await testResp.json(); + console.log('Test connection:', { accounts: testBody.accounts, streams: testBody.streams, hint: testBody.hint }); + expect(testResp.ok()).toBeTruthy(); + }); + + test('Step 2: View Config-UI Home', async ({ page }) => { + await page.goto(UI); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1000); + await page.screenshot({ path: path.join(SCREENSHOT_DIR, '01-config-ui-home.png'), fullPage: true }); + console.log('Screenshot: Config-UI home'); + }); + + test('Step 3: Create Scope (S3 Slice) via API', async () => { + const api = await request.newContext({ baseURL: API }); + + const resp = await api.put(`/plugins/kiro/connections/${state.connectionId}/scopes`, { + data: { + data: [ + { + accountId: KIRO_ACCOUNT_ID, + basePath: '', + year: 2026, + month: 3, + }, + ], + }, + }); + + const body = await resp.json(); + console.log('Scope created:', resp.status()); + expect(resp.ok()).toBeTruthy(); + state.scopeId = body[0]?.id; + expect(state.scopeId).toBeTruthy(); + console.log(`Scope id: ${state.scopeId}`); + }); + + test('Step 4: Create Blueprint via API', async () => { + const api = await request.newContext({ baseURL: API }); + + const resp = await api.post('/blueprints', { + data: { + name: `e2e-blueprint-${Date.now()}`, + mode: 'NORMAL', + enable: true, + cronConfig: '0 0 * * *', + isManual: true, + connections: [ + { + pluginName: 'kiro', + connectionId: state.connectionId, + scopes: [{ scopeId: state.scopeId }], + }, + ], + }, + }); + + const body = await resp.json(); + expect(resp.ok()).toBeTruthy(); + state.blueprintId = body.id; + console.log(`Blueprint created: id=${state.blueprintId}`); + }); + + test('Step 5: Trigger Pipeline via API', async () => { + const api = await request.newContext({ baseURL: API }); + + const resp = await api.post(`/blueprints/${state.blueprintId}/trigger`, { data: {} }); + const body = await resp.json(); + expect(resp.ok()).toBeTruthy(); + state.pipelineId = body.id; + console.log(`Pipeline triggered: id=${state.pipelineId}`); + }); + + test('Step 6: Wait for Pipeline to Complete', async () => { + const api = await request.newContext({ baseURL: API }); + const maxWait = 120000; + const start = Date.now(); + let status = ''; + + while (Date.now() - start < maxWait) { + const resp = await api.get(`/pipelines/${state.pipelineId}`); + const pipeline = await resp.json(); + status = pipeline.status; + console.log(`Pipeline status: ${status} (${Math.round((Date.now() - start) / 1000)}s)`); + if (['TASK_COMPLETED', 'TASK_FAILED', 'TASK_PARTIAL'].includes(status)) break; + await new Promise((r) => setTimeout(r, 3000)); + } + + // Print task details + const tasksResp = await api.get(`/pipelines/${state.pipelineId}/tasks`); + if (tasksResp.ok()) { + const { tasks } = await tasksResp.json(); + for (const t of tasks || []) { + console.log(` Task ${t.id}: ${t.status}${t.failedSubTask ? ` (failed: ${t.failedSubTask})` : ''}`); + if (t.message) console.log(` Error: ${t.message.substring(0, 300)}`); + } + } + + expect(status).toBe('TASK_COMPLETED'); + }); + + test('Step 7: Verify Data via MySQL', async () => { + const api = await request.newContext({ baseURL: API }); + + // Use pipeline tasks to confirm data was processed + const tasksResp = await api.get(`/pipelines/${state.pipelineId}/tasks`); + const { tasks } = await tasksResp.json(); + expect(tasks[0].status).toBe('TASK_COMPLETED'); + console.log(`Pipeline completed in ${tasks[0].spentSeconds}s`); + }); + + test('Step 8: Grafana - Kiro Usage Dashboard (new format)', async ({ page }) => { + await openGrafanaDashboard(page, 'kiro_user_report', path.join(SCREENSHOT_DIR, '02-dashboard-user-report.png')); + console.log('Screenshot: Kiro Usage Dashboard'); + }); + + test('Step 9: Grafana - Kiro Feature Metrics', async ({ page }) => { + await openGrafanaDashboard(page, 'kiro_feature_metrics', path.join(SCREENSHOT_DIR, '03-dashboard-feature-metrics.png')); + console.log('Screenshot: Kiro Feature Metrics'); + }); + + test('Step 10: Grafana - Kiro AI Activity Insights (logging)', async ({ page }) => { + await openGrafanaDashboard(page, 'kiro_logging', path.join(SCREENSHOT_DIR, '04-dashboard-logging.png')); + console.log('Screenshot: Kiro AI Activity Insights'); + }); + + test('Step 11: Grafana - Kiro Executive Dashboard', async ({ page }) => { + await openGrafanaDashboard(page, 'kiro_executive', path.join(SCREENSHOT_DIR, '05-dashboard-executive.png')); + console.log('Screenshot: Kiro Executive Dashboard'); + }); + + test('Step 12: View Pipeline in Config-UI', async ({ page }) => { + // Navigate to the API proxy route for pipelines + await page.goto(`${UI}/api/pipelines?pageSize=5`); + await page.waitForLoadState('networkidle'); + await page.screenshot({ path: path.join(SCREENSHOT_DIR, '06-config-ui-pipelines.png'), fullPage: true }); + console.log('Screenshot: Pipelines API response'); + }); + + test('Step 13: Cleanup', async () => { + const api = await request.newContext({ baseURL: API }); + if (state.blueprintId) { + await api.delete(`/blueprints/${state.blueprintId}`); + console.log(`Deleted blueprint ${state.blueprintId}`); + } + console.log('Cleanup complete'); + }); +}); From bc2fdf7486a80b085b0837a9b9014b137c82e976 Mon Sep 17 00:00:00 2001 From: warren Date: Fri, 28 Aug 2026 14:32:51 +0800 Subject: [PATCH 3/4] fix(kiro): use portable timestamp precision Signed-off-by: warren --- backend/plugins/kiro/models/chat_log.go | 2 +- backend/plugins/kiro/models/completion_log.go | 2 +- .../models/migrationscripts/archived/init.go | 4 +- .../kiro/models/timestamp_schema_test.go | 55 +++++++++++++++++++ 4 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 backend/plugins/kiro/models/timestamp_schema_test.go diff --git a/backend/plugins/kiro/models/chat_log.go b/backend/plugins/kiro/models/chat_log.go index ecd943696fe..cde329f517d 100644 --- a/backend/plugins/kiro/models/chat_log.go +++ b/backend/plugins/kiro/models/chat_log.go @@ -44,7 +44,7 @@ type KiroChatLog struct { UserId string `gorm:"type:varchar(64);index" json:"userId"` // IdentityStoreId is the stripped prefix, retained for auditability. IdentityStoreId string `gorm:"type:varchar(32)" json:"identityStoreId"` - Timestamp time.Time `gorm:"type:datetime(6);index" json:"timestamp"` + Timestamp time.Time `gorm:"precision:6;index" json:"timestamp"` // ChatTriggerType is MANUAL or INLINE_CHAT per the docs; only MANUAL has // been observed. Not validated against a fixed set. ChatTriggerType string `gorm:"type:varchar(20)" json:"chatTriggerType"` diff --git a/backend/plugins/kiro/models/completion_log.go b/backend/plugins/kiro/models/completion_log.go index 0d09c28e0ad..cfd633e5335 100644 --- a/backend/plugins/kiro/models/completion_log.go +++ b/backend/plugins/kiro/models/completion_log.go @@ -40,7 +40,7 @@ type KiroCompletionLog struct { UserId string `gorm:"type:varchar(64);index" json:"userId"` IdentityStoreId string `gorm:"type:varchar(32)" json:"identityStoreId"` - Timestamp time.Time `gorm:"type:datetime(6);index" json:"timestamp"` + Timestamp time.Time `gorm:"precision:6;index" json:"timestamp"` // FileName has no path component; see the type comment. FileName string `gorm:"type:varchar(255);index" json:"fileName"` diff --git a/backend/plugins/kiro/models/migrationscripts/archived/init.go b/backend/plugins/kiro/models/migrationscripts/archived/init.go index 5c1c9a3224f..53422db0499 100644 --- a/backend/plugins/kiro/models/migrationscripts/archived/init.go +++ b/backend/plugins/kiro/models/migrationscripts/archived/init.go @@ -127,7 +127,7 @@ type KiroChatLog struct { RequestId string `gorm:"primaryKey;type:varchar(64)"` UserId string `gorm:"type:varchar(64);index" json:"userId"` IdentityStoreId string `gorm:"type:varchar(32)" json:"identityStoreId"` - Timestamp time.Time `gorm:"type:datetime(6);index" json:"timestamp"` + Timestamp time.Time `gorm:"precision:6;index" json:"timestamp"` ChatTriggerType string `gorm:"type:varchar(20)" json:"chatTriggerType"` ModelId *string `gorm:"type:varchar(100)" json:"modelId"` HasPrompt bool `gorm:"index" json:"hasPrompt"` @@ -152,7 +152,7 @@ type KiroCompletionLog struct { RequestId string `gorm:"primaryKey;type:varchar(64)"` UserId string `gorm:"type:varchar(64);index" json:"userId"` IdentityStoreId string `gorm:"type:varchar(32)" json:"identityStoreId"` - Timestamp time.Time `gorm:"type:datetime(6);index" json:"timestamp"` + Timestamp time.Time `gorm:"precision:6;index" json:"timestamp"` FileName string `gorm:"type:varchar(255);index" json:"fileName"` FileExtension string `gorm:"type:varchar(50)" json:"fileExtension"` HasCustomization bool `json:"hasCustomization"` diff --git a/backend/plugins/kiro/models/timestamp_schema_test.go b/backend/plugins/kiro/models/timestamp_schema_test.go new file mode 100644 index 00000000000..4bc479b1c62 --- /dev/null +++ b/backend/plugins/kiro/models/timestamp_schema_test.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 ( + "sync" + "testing" + + "github.com/apache/incubator-devlake/plugins/kiro/models/migrationscripts/archived" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + mysqlDriver "gorm.io/driver/mysql" + postgresDriver "gorm.io/driver/postgres" + "gorm.io/gorm/schema" +) + +func TestLogTimestampUsesPortableMicrosecondPrecision(t *testing.T) { + logModels := []struct { + name string + model any + }{ + {name: "chat runtime model", model: &KiroChatLog{}}, + {name: "completion runtime model", model: &KiroCompletionLog{}}, + {name: "chat migration model", model: &archived.KiroChatLog{}}, + {name: "completion migration model", model: &archived.KiroCompletionLog{}}, + } + + for _, logModel := range logModels { + t.Run(logModel.name, func(t *testing.T) { + sch, err := schema.Parse(logModel.model, &sync.Map{}, schema.NamingStrategy{}) + require.NoError(t, err) + + field := sch.LookUpField("Timestamp") + require.NotNil(t, field) + assert.Equal(t, 6, field.Precision) + assert.Equal(t, "datetime(6) NULL", mysqlDriver.New(mysqlDriver.Config{}).DataTypeOf(field)) + assert.Equal(t, "timestamptz(6)", postgresDriver.New(postgresDriver.Config{}).DataTypeOf(field)) + }) + } +} From 5e52b0cba885e6204badb88d712e9e2f8b377b97 Mon Sep 17 00:00:00 2001 From: warren Date: Fri, 28 Aug 2026 15:00:01 +0800 Subject: [PATCH 4/4] fix(kiro): keep q_dev in table info checks Signed-off-by: warren --- backend/plugins/table_info_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/plugins/table_info_test.go b/backend/plugins/table_info_test.go index f4182fe9d89..afe5fa1a31d 100644 --- a/backend/plugins/table_info_test.go +++ b/backend/plugins/table_info_test.go @@ -52,6 +52,7 @@ import ( opsgenie "github.com/apache/incubator-devlake/plugins/opsgenie/impl" org "github.com/apache/incubator-devlake/plugins/org/impl" pagerduty "github.com/apache/incubator-devlake/plugins/pagerduty/impl" + q_dev "github.com/apache/incubator-devlake/plugins/q_dev/impl" refdiff "github.com/apache/incubator-devlake/plugins/refdiff/impl" rootly "github.com/apache/incubator-devlake/plugins/rootly/impl" slack "github.com/apache/incubator-devlake/plugins/slack/impl" @@ -114,6 +115,7 @@ func Test_GetPluginTablesInfo(t *testing.T) { checker.FeedIn("opsgenie/models", opsgenie.Opsgenie{}.GetTablesInfo) checker.FeedIn("linker/models", linker.Linker{}.GetTablesInfo) checker.FeedIn("issue_trace/models", issueTrace.IssueTrace{}.GetTablesInfo) + checker.FeedIn("q_dev/models", q_dev.QDev{}.GetTablesInfo) checker.FeedIn("kiro/models", kiro.Kiro{}.GetTablesInfo) checker.FeedIn("gh-copilot/models", copilot.GhCopilot{}.GetTablesInfo) err := checker.Verify()