blob: 93e9589c93b1e8ae693b99ee1c6b5bf4623c85aa [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"
"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/clickup/models"
)
// ClickUpRemotePagination is a placeholder: the ClickUp v2 hierarchy endpoints
// used here (team/space/folder) are not paginated, so there is never a next
// page. It exists to satisfy the DsRemoteApiScopeListHelper generic.
type ClickUpRemotePagination struct{}
// groupId prefixes encode which level of the ClickUp hierarchy a group entry
// points at, so a single list function can walk Team -> Space -> Folder. The
// selectable scope is the Folder (= board); spaces and teams are navigation
// groups only.
const (
groupTeamPrefix = "team:"
groupSpacePrefix = "space:"
)
type clickUpNamedEntities struct {
Teams []clickUpNamedEntity `json:"teams"`
Spaces []clickUpNamedEntity `json:"spaces"`
Folders []clickUpNamedEntity `json:"folders"`
}
type clickUpNamedEntity struct {
Id string `json:"id"`
Name string `json:"name"`
}
// listClickUpRemoteScopes walks the ClickUp hierarchy one level at a time. The
// config UI drives it via `groupId`:
//
// "" -> workspaces (Team) as groups
// team:{id} -> spaces as groups
// space:{id} -> folders as selectable scopes (boards)
func listClickUpRemoteScopes(
_ *models.ClickUpConnection,
apiClient plugin.ApiClient,
groupId string,
_ ClickUpRemotePagination,
) (
children []dsmodels.DsRemoteApiScopeListEntry[models.ClickUpFolder],
nextPage *ClickUpRemotePagination,
err errors.Error,
) {
switch {
case groupId == "":
return listTeamsAsGroups(apiClient)
case strings.HasPrefix(groupId, groupTeamPrefix):
return listSpacesAsGroups(apiClient, strings.TrimPrefix(groupId, groupTeamPrefix), groupId)
case strings.HasPrefix(groupId, groupSpacePrefix):
return listFoldersAsScopes(apiClient, strings.TrimPrefix(groupId, groupSpacePrefix), groupId)
default:
return nil, nil, errors.BadInput.New(fmt.Sprintf("unrecognized groupId %q", groupId))
}
}
func getEntities(apiClient plugin.ApiClient, path string) (*clickUpNamedEntities, errors.Error) {
res, err := apiClient.Get(path, nil, nil)
if err != nil {
return nil, errors.Default.Wrap(err, "failed to query ClickUp "+path)
}
var body clickUpNamedEntities
if err := api.UnmarshalResponse(res, &body); err != nil {
return nil, errors.Default.Wrap(err, "failed to unmarshal ClickUp "+path+" response")
}
return &body, nil
}
// groupEntry builds a navigation-group row. parentId is the id of the group
// this row is nested under (nil for the top level); the miller-column UI uses
// it to render children in the next column instead of inline.
func groupEntry(id, name string, parentId *string) dsmodels.DsRemoteApiScopeListEntry[models.ClickUpFolder] {
return dsmodels.DsRemoteApiScopeListEntry[models.ClickUpFolder]{
Type: api.RAS_ENTRY_TYPE_GROUP,
ParentId: parentId,
Id: id,
Name: name,
FullName: name,
}
}
func listTeamsAsGroups(apiClient plugin.ApiClient) ([]dsmodels.DsRemoteApiScopeListEntry[models.ClickUpFolder], *ClickUpRemotePagination, errors.Error) {
body, err := getEntities(apiClient, "team")
if err != nil {
return nil, nil, err
}
children := make([]dsmodels.DsRemoteApiScopeListEntry[models.ClickUpFolder], 0, len(body.Teams))
for _, team := range body.Teams {
children = append(children, groupEntry(groupTeamPrefix+team.Id, team.Name, nil))
}
return children, nil, nil
}
func listSpacesAsGroups(apiClient plugin.ApiClient, teamId, parentId string) ([]dsmodels.DsRemoteApiScopeListEntry[models.ClickUpFolder], *ClickUpRemotePagination, errors.Error) {
body, err := getEntities(apiClient, fmt.Sprintf("team/%s/space", teamId))
if err != nil {
return nil, nil, err
}
children := make([]dsmodels.DsRemoteApiScopeListEntry[models.ClickUpFolder], 0, len(body.Spaces))
for _, space := range body.Spaces {
children = append(children, groupEntry(groupSpacePrefix+space.Id, space.Name, &parentId))
}
return children, nil, nil
}
// listFoldersAsScopes returns a space's folders as selectable (leaf) scope
// entries — the folder is the board a user picks. parentId nests them under the
// space column.
func listFoldersAsScopes(apiClient plugin.ApiClient, spaceId, parentId string) ([]dsmodels.DsRemoteApiScopeListEntry[models.ClickUpFolder], *ClickUpRemotePagination, errors.Error) {
body, err := getEntities(apiClient, fmt.Sprintf("space/%s/folder", spaceId))
if err != nil {
return nil, nil, err
}
entries := make([]dsmodels.DsRemoteApiScopeListEntry[models.ClickUpFolder], 0, len(body.Folders))
for _, folder := range body.Folders {
folder := folder
entries = append(entries, dsmodels.DsRemoteApiScopeListEntry[models.ClickUpFolder]{
Type: api.RAS_ENTRY_TYPE_SCOPE,
ParentId: &parentId,
Id: folder.Id,
Name: folder.Name,
FullName: folder.Name,
Data: &models.ClickUpFolder{
FolderId: folder.Id,
Name: folder.Name,
SpaceId: spaceId,
SpaceName: "",
},
})
}
return entries, nil, nil
}
// RemoteScopes lists the ClickUp folders available on the connection so the
// config UI can enumerate selectable scopes.
func RemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
return raScopeList.Get(input)
}
// Proxy forwards arbitrary requests to the ClickUp API through the connection.
func Proxy(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
return raProxy.Proxy(input)
}