Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions backend/plugins/claude_code/api/blueprint_v200.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file distributed with
this work for additional information regarding copyright ownership.
The ASF licenses this file to You under the Apache License, Version 2.0
(the "License"); you may not use this file except in compliance with
the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package 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/claude_code/models"
"github.com/apache/incubator-devlake/plugins/claude_code/tasks"
)

// MakeDataSourcePipelinePlanV200 generates the pipeline plan for blueprint v2.0.0.
func MakeDataSourcePipelinePlanV200(
subtaskMetas []plugin.SubTaskMeta,
connectionId uint64,
bpScopes []*coreModels.BlueprintScope,
) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) {
_, 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)
if err != nil {
return nil, nil, err
}

return plan, nil, nil
}

func makeDataSourcePipelinePlanV200(
subtaskMetas []plugin.SubTaskMeta,
scopeDetails []*srvhelper.ScopeDetail[models.ClaudeCodeScope, models.ClaudeCodeScopeConfig],
) (coreModels.PipelinePlan, errors.Error) {
plan := make(coreModels.PipelinePlan, len(scopeDetails))
for i, scopeDetail := range scopeDetails {
stage := plan[i]
if stage == nil {
stage = coreModels.PipelineStage{}
}

scope := scopeDetail.Scope
task, err := helper.MakePipelinePlanTask(
"claude_code",
subtaskMetas,
nil,
tasks.ClaudeCodeOptions{
ConnectionId: scope.ConnectionId,
ScopeId: scope.Id,
},
)
if err != nil {
return nil, err
}
stage = append(stage, task)
plan[i] = stage
}
return plan, nil
}
110 changes: 110 additions & 0 deletions backend/plugins/claude_code/api/connection.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
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 (
"strings"

"github.com/apache/incubator-devlake/core/errors"
"github.com/apache/incubator-devlake/core/plugin"
helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
"github.com/apache/incubator-devlake/plugins/claude_code/models"
)

// PostConnections creates a new Claude Code connection.
func PostConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
connection := &models.ClaudeCodeConnection{}
if err := helper.Decode(input.Body, connection, vld); err != nil {
return nil, err
}

connection.Normalize()
if err := validateConnection(connection); err != nil {
return nil, err
}

if err := connectionHelper.Create(connection, input); err != nil {
return nil, err
}
return &plugin.ApiResourceOutput{Body: connection.Sanitize()}, nil
}

func PatchConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
connection := &models.ClaudeCodeConnection{}
if err := connectionHelper.First(connection, input.Params); err != nil {
return nil, err
}
if err := (&models.ClaudeCodeConnection{}).MergeFromRequest(connection, input.Body); err != nil {
return nil, errors.Convert(err)
}
connection.Normalize()
if err := validateConnection(connection); err != nil {
return nil, err
}
if err := connectionHelper.SaveWithCreateOrUpdate(connection); err != nil {
return nil, err
}
return &plugin.ApiResourceOutput{Body: connection.Sanitize()}, nil
}

func DeleteConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
conn := &models.ClaudeCodeConnection{}
output, err := connectionHelper.Delete(conn, input)
if err != nil {
return output, err
}
output.Body = conn.Sanitize()
return output, nil
}

func ListConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
var connections []models.ClaudeCodeConnection
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
}

func GetConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
connection := &models.ClaudeCodeConnection{}
if err := connectionHelper.First(connection, input.Params); err != nil {
return nil, err
}
return &plugin.ApiResourceOutput{Body: connection.Sanitize()}, nil
}

func validateConnection(connection *models.ClaudeCodeConnection) errors.Error {
if connection == nil {
return errors.BadInput.New("connection is required")
}
hasToken := strings.TrimSpace(connection.Token) != ""
hasCustomHeaders := len(connection.CustomHeaders) > 0
if !hasToken && !hasCustomHeaders {
return errors.BadInput.New("either token or at least one custom header is required")
}
if strings.TrimSpace(connection.Organization) == "" {
return errors.BadInput.New("organization is required")
}
Comment on lines +98 to +105
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validateConnection treats any non-empty CustomHeaders slice as valid authentication. This allows saving/testing a connection with only blank header entries (e.g., {key:'', value:''}), which will not set any headers in SetupAuthentication and will lead to auth failures at runtime. Consider validating that at least one custom header has a non-empty key (and likely a non-empty value) before accepting it as an auth alternative to Token.

Copilot uses AI. Check for mistakes.
if connection.RateLimitPerHour < 0 {
return errors.BadInput.New("rateLimitPerHour must be non-negative")
}
return nil
}
103 changes: 103 additions & 0 deletions backend/plugins/claude_code/api/connection_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
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 (
"testing"

"github.com/stretchr/testify/assert"

"github.com/apache/incubator-devlake/plugins/claude_code/models"
)

const (
testOrganization = "anthropic-labs"
testToken = "sk-ant-example"
)

func TestValidateConnectionSuccess(t *testing.T) {
connection := &models.ClaudeCodeConnection{
ClaudeCodeConn: models.ClaudeCodeConn{
Organization: testOrganization,
Token: testToken,
},
}
connection.Normalize()

err := validateConnection(connection)
assert.NoError(t, err)
}

func TestValidateConnectionNil(t *testing.T) {
err := validateConnection(nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "connection is required")
}

func TestValidateConnectionMissingOrganization(t *testing.T) {
connection := &models.ClaudeCodeConnection{
ClaudeCodeConn: models.ClaudeCodeConn{
Token: testToken,
},
}

err := validateConnection(connection)
assert.Error(t, err)
assert.Contains(t, err.Error(), "organization is required")
}

func TestValidateConnectionMissingToken(t *testing.T) {
connection := &models.ClaudeCodeConnection{
ClaudeCodeConn: models.ClaudeCodeConn{
Organization: testOrganization,
},
}

err := validateConnection(connection)
assert.Error(t, err)
assert.Contains(t, err.Error(), "either token or at least one custom header is required")
}

func TestValidateConnectionCustomHeadersWithoutToken(t *testing.T) {
connection := &models.ClaudeCodeConnection{
ClaudeCodeConn: models.ClaudeCodeConn{
Organization: testOrganization,
CustomHeaders: []models.CustomHeader{
{Key: "Ocp-Apim-Subscription-Key", Value: "secret-key"},
},
},
}
connection.Normalize()

err := validateConnection(connection)
assert.NoError(t, err)
}

func TestValidateConnectionInvalidRateLimit(t *testing.T) {
connection := &models.ClaudeCodeConnection{
ClaudeCodeConn: models.ClaudeCodeConn{
Organization: testOrganization,
Token: testToken,
},
}
connection.RateLimitPerHour = -1

err := validateConnection(connection)
assert.Error(t, err)
assert.Contains(t, err.Error(), "rateLimitPerHour must be non-negative")
}
58 changes: 58 additions & 0 deletions backend/plugins/claude_code/api/init.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
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"
helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
"github.com/apache/incubator-devlake/plugins/claude_code/models"
)

var (
basicRes context.BasicRes
vld *validator.Validate
connectionHelper *helper.ConnectionApiHelper
dsHelper *helper.DsHelper[models.ClaudeCodeConnection, models.ClaudeCodeScope, models.ClaudeCodeScopeConfig]
raProxy *helper.DsRemoteApiProxyHelper[models.ClaudeCodeConnection]
raScopeList *helper.DsRemoteApiScopeListHelper[models.ClaudeCodeConnection, models.ClaudeCodeScope, ClaudeCodeRemotePagination]
)

// Init stores basic resources and configures shared helpers for API handlers.
func Init(br context.BasicRes, meta plugin.PluginMeta) {
basicRes = br
vld = validator.New()
connectionHelper = helper.NewConnectionHelper(basicRes, vld, meta.Name())
dsHelper = helper.NewDataSourceHelper[
models.ClaudeCodeConnection, models.ClaudeCodeScope, models.ClaudeCodeScopeConfig,
](
basicRes,
meta.Name(),
[]string{"id", "organizationId"},
func(c models.ClaudeCodeConnection) models.ClaudeCodeConnection {
c.Normalize()
return c.Sanitize()
},
func(s models.ClaudeCodeScope) models.ClaudeCodeScope { return s },
nil,
)
raProxy = helper.NewDsRemoteApiProxyHelper[models.ClaudeCodeConnection](dsHelper.ConnApi.ModelApiHelper)
raScopeList = helper.NewDsRemoteApiScopeListHelper[models.ClaudeCodeConnection, models.ClaudeCodeScope, ClaudeCodeRemotePagination](raProxy, listClaudeCodeRemoteScopes)
}
Loading
Loading