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
92 changes: 92 additions & 0 deletions backend/plugins/clickup/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<!--
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.
-->

# ClickUp

The ClickUp plugin collects issues, boards, and sprints from ClickUp so they
feed DevLake's issue-tracking and DORA/velocity metrics, modeled on the Jira
and Linear connectors.

## Authentication

ClickUp authenticates with a **personal API token** (ClickUp → Settings → Apps
→ API Token, starts with `pk_`). The token is sent verbatim in the
`Authorization` header. OAuth is not yet supported.

Create a connection with:

- **Endpoint** — `https://api.clickup.com/api/v2/`
- **Token** — your `pk_...` personal token

## Data scope: the folder is the board

Unlike a raw list, the **scope you select is a ClickUp folder** (e.g. a team's
`Dev Team` / `Sprint Folder`). This mirrors a Jira board: selecting the folder
collects every list inside it on each sync, so rolling and archived sprint
lists never need to be re-scoped.

Domain mapping:

| ClickUp | DevLake domain |
| --- | --- |
| Folder | `board` |
| Sprint list (name-matched) | `sprint` + `board_sprint` |
| Task | `issue` + `board_issue` |
| Task in a sprint list | `sprint_issue` |
| Folder member | `account` |

## Sprints

A list is treated as a sprint when its name matches the **sprint name pattern**
(default `(?i)sprint\s*\d+`, e.g. `v4.3.0 Sprint 40 (7/6/26 - 7/19/26)`). The
start/end dates are parsed from the parenthesised date span in the name;
`M/D/YY` vs `D/M/YY` ordering is disambiguated automatically (a component > 12
must be the day). Lists that don't match are collected as plain board issues.
Archived sprint lists are collected too, so historical velocity is retained.

## Story points

Story points default to ClickUp's native sprint **`points`** field. To read
them from a custom field instead (e.g. a Fibonacci "LOE" field), set
**Story point field** in the scope config to the custom-field name.

## Incidents (DORA Change Failure Rate / MTTR)

ClickUp tasks have no universal "type" field, so incidents are modeled by
**scope**: add the folder that holds incidents (e.g. a "Security Incidents"
folder) as its own board and set the scope config's **Force issue type** to
`INCIDENT`. Every issue on that board is then classified as an incident.

## Scope configuration (transformation)

Per board, the scope config lets you override:

- **Sprint name pattern** — which lists are sprints.
- **Story point field** — native `points` (blank) or a custom field name.
- **Force issue type** — flag the whole board as `REQUIREMENT` / `BUG` /
`INCIDENT` (leave blank to detect per task).
- **Issue type patterns** — RegExes matched against a task's type; precedence
is `INCIDENT` > `BUG` > `REQUIREMENT`.
- **Status mapping** — ClickUp statuses are auto-mapped by their `type`
(`open`/`unstarted` → `TODO`, `custom` → `IN_PROGRESS`, `done`/`closed` →
`DONE`); list raw status names to override where a team's workflow differs.

## Metrics

A branded **ClickUp** Grafana dashboard (`grafana/dashboards/{mysql,postgresql}/ClickUp.json`)
renders issue throughput and lead-time metrics; DORA and sprint/velocity
metrics are computed from the source-agnostic domain layer.
99 changes: 99 additions & 0 deletions backend/plugins/clickup/api/blueprint_v200.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file distributed with
this work for additional information regarding copyright ownership.
The ASF licenses this file to You under the Apache License, Version 2.0
(the "License"); you may not use this file except in compliance with
the License. You may obtain a copy of the License at

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

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

package api

import (
"github.com/apache/incubator-devlake/core/errors"
coreModels "github.com/apache/incubator-devlake/core/models"
"github.com/apache/incubator-devlake/core/models/domainlayer/didgen"
"github.com/apache/incubator-devlake/core/models/domainlayer/ticket"
"github.com/apache/incubator-devlake/core/plugin"
"github.com/apache/incubator-devlake/core/utils"
helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
"github.com/apache/incubator-devlake/helpers/srvhelper"
"github.com/apache/incubator-devlake/plugins/clickup/models"
"github.com/apache/incubator-devlake/plugins/clickup/tasks"
)

func MakePipelinePlanV200(
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 := makePipelinePlanV200(subtaskMetas, scopeDetails, connection)
if err != nil {
return nil, nil, err
}
scopes, err := makeScopesV200(scopeDetails, connection)
return plan, scopes, err
}

func makePipelinePlanV200(
subtaskMetas []plugin.SubTaskMeta,
scopeDetails []*srvhelper.ScopeDetail[models.ClickUpFolder, models.ClickUpScopeConfig],
connection *models.ClickUpConnection,
) (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, scopeConfig := scopeDetail.Scope, scopeDetail.ScopeConfig
task, err := helper.MakePipelinePlanTask(
"clickup",
subtaskMetas,
scopeConfig.Entities,
tasks.ClickUpOptions{
ConnectionId: connection.ID,
FolderId: scope.FolderId,
ScopeConfigId: scope.ScopeConfigId,
},
)
if err != nil {
return nil, err
}
stage = append(stage, task)
plan[i] = stage
}
return plan, nil
}

func makeScopesV200(
scopeDetails []*srvhelper.ScopeDetail[models.ClickUpFolder, models.ClickUpScopeConfig],
connection *models.ClickUpConnection,
) ([]plugin.Scope, errors.Error) {
scopes := make([]plugin.Scope, 0, len(scopeDetails))
idgen := didgen.NewDomainIdGenerator(&models.ClickUpFolder{})
for _, scopeDetail := range scopeDetails {
scope, scopeConfig := scopeDetail.Scope, scopeDetail.ScopeConfig
id := idgen.Generate(connection.ID, scope.FolderId)
if utils.StringsContains(scopeConfig.Entities, plugin.DOMAIN_TYPE_TICKET) {
scopes = append(scopes, ticket.NewBoard(id, scope.Name))
}
}
return scopes, nil
}
175 changes: 175 additions & 0 deletions backend/plugins/clickup/api/connection_api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/*
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 (
"context"
"net/http"

"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/clickup/models"
"github.com/apache/incubator-devlake/plugins/clickup/tasks"
"github.com/apache/incubator-devlake/server/api/shared"
)

type ClickUpTestConnResponse struct {
shared.ApiBody
Connection *models.ClickUpConn
}

func testConnection(ctx context.Context, connection models.ClickUpConn) (*ClickUpTestConnResponse, errors.Error) {
if vld != nil {
if err := vld.Struct(connection); err != nil {
return nil, errors.Default.Wrap(err, "error validating target")
}
}
if connection.Endpoint == "" {
connection.Endpoint = tasks.DefaultEndpoint
}
apiClient, err := helper.NewApiClientFromConnection(ctx, basicRes, &connection)
if err != nil {
return nil, err
}
// GET /team lists the authenticated user's workspaces; it verifies the token.
res, err := apiClient.Get("team", nil, nil)
if err != nil {
return nil, errors.BadInput.Wrap(err, "verify token failed")
}
if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden {
return nil, errors.HttpStatus(http.StatusBadRequest).New("authentication failed, please check your API token")
}
if res.StatusCode != http.StatusOK {
return nil, errors.HttpStatus(res.StatusCode).New("unexpected status code while testing connection")
}
connection = connection.Sanitize()
body := ClickUpTestConnResponse{}
body.Success = true
body.Message = "success"
body.Connection = &connection
return &body, nil
}

// TestConnection test clickup connection
// @Summary test clickup connection
// @Description Test clickup Connection
// @Tags plugins/clickup
// @Param body body models.ClickUpConn true "json body"
// @Success 200 {object} ClickUpTestConnResponse "Success"
// @Failure 400 {string} errcode.Error "Bad Request"
// @Failure 500 {string} errcode.Error "Internal Error"
// @Router /plugins/clickup/test [POST]
func TestConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
var connection models.ClickUpConn
if err := helper.Decode(input.Body, &connection, vld); err != nil {
return nil, err
}
result, err := testConnection(context.TODO(), connection)
if err != nil {
return nil, plugin.WrapTestConnectionErrResp(basicRes, err)
}
return &plugin.ApiResourceOutput{Body: result, Status: http.StatusOK}, nil
}

// TestExistingConnection test clickup connection by ID
// @Summary test clickup connection
// @Description Test clickup Connection
// @Tags plugins/clickup
// @Param connectionId path int true "connection ID"
// @Success 200 {object} ClickUpTestConnResponse "Success"
// @Failure 400 {string} errcode.Error "Bad Request"
// @Failure 500 {string} errcode.Error "Internal Error"
// @Router /plugins/clickup/connections/{connectionId}/test [POST]
func TestExistingConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
connection, err := dsHelper.ConnApi.GetMergedConnection(input)
if err != nil {
return nil, errors.BadInput.Wrap(err, "find connection from db")
}
if err := helper.DecodeMapStruct(input.Body, connection, false); err != nil {
return nil, err
}
result, testErr := testConnection(context.TODO(), connection.ClickUpConn)
if testErr != nil {
return nil, plugin.WrapTestConnectionErrResp(basicRes, testErr)
}
return &plugin.ApiResourceOutput{Body: result, Status: http.StatusOK}, nil
}

// PostConnections create clickup connection
// @Summary create clickup connection
// @Description Create clickup connection
// @Tags plugins/clickup
// @Param body body models.ClickUpConnection true "json body"
// @Success 200 {object} models.ClickUpConnection
// @Failure 400 {string} errcode.Error "Bad Request"
// @Failure 500 {string} errcode.Error "Internal Error"
// @Router /plugins/clickup/connections [POST]
func PostConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
return dsHelper.ConnApi.Post(input)
}

// PatchConnection patch clickup connection
// @Summary patch clickup connection
// @Description Patch clickup connection
// @Tags plugins/clickup
// @Param body body models.ClickUpConnection true "json body"
// @Success 200 {object} models.ClickUpConnection
// @Failure 400 {string} errcode.Error "Bad Request"
// @Failure 500 {string} errcode.Error "Internal Error"
// @Router /plugins/clickup/connections/{connectionId} [PATCH]
func PatchConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
return dsHelper.ConnApi.Patch(input)
}

// DeleteConnection delete a clickup connection
// @Summary delete a clickup connection
// @Description Delete a clickup connection
// @Tags plugins/clickup
// @Success 200 {object} models.ClickUpConnection
// @Failure 400 {string} errcode.Error "Bad Request"
// @Failure 409 {object} services.BlueprintProjectPairs "References exist to this connection"
// @Failure 500 {string} errcode.Error "Internal Error"
// @Router /plugins/clickup/connections/{connectionId} [DELETE]
func DeleteConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
return dsHelper.ConnApi.Delete(input)
}

// ListConnections get all clickup connections
// @Summary get all clickup connections
// @Description Get all clickup connections
// @Tags plugins/clickup
// @Success 200 {object} []models.ClickUpConnection
// @Failure 400 {string} errcode.Error "Bad Request"
// @Failure 500 {string} errcode.Error "Internal Error"
// @Router /plugins/clickup/connections [GET]
func ListConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
return dsHelper.ConnApi.GetAll(input)
}

// GetConnection get clickup connection detail
// @Summary get clickup connection detail
// @Description Get clickup connection detail
// @Tags plugins/clickup
// @Success 200 {object} models.ClickUpConnection
// @Failure 400 {string} errcode.Error "Bad Request"
// @Failure 500 {string} errcode.Error "Internal Error"
// @Router /plugins/clickup/connections/{connectionId} [GET]
func GetConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
return dsHelper.ConnApi.GetDetail(input)
}
Loading