blob: f555c60a56906eb29ff04143d1e697db9f53ee4b [file]
/*
Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file distributed with
this work for additional information regarding copyright ownership.
The ASF licenses this file to You under the Apache License, Version 2.0
(the "License"); you may not use this file except in compliance with
the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package api
import (
"fmt"
"strconv"
"strings"
"github.com/apache/devlake/core/errors"
"github.com/apache/devlake/core/plugin"
"github.com/apache/devlake/helpers/pluginhelper/api"
dsmodels "github.com/apache/devlake/helpers/pluginhelper/api/models"
"github.com/apache/devlake/plugins/kiro/models"
"github.com/apache/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
}