Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
db18be6
feat(linear): add tool-layer models and init migration
eduardoarantes May 29, 2026
1009c4e
feat(linear): add plugin skeleton, connection API and GraphQL client
eduardoarantes May 29, 2026
e1e65e1
feat(linear): collect, extract and convert users to accounts
eduardoarantes May 29, 2026
a076472
feat(linear): collect and extract workflow states
eduardoarantes May 29, 2026
3f23db2
feat(linear): collect, extract and convert issues
eduardoarantes May 29, 2026
ce707df
feat(linear): collect, extract and convert issue comments
eduardoarantes May 29, 2026
ea8d79f
feat(linear): convert issue labels to domain layer
eduardoarantes May 29, 2026
4789ba0
feat(linear): collect cycles and convert to sprints
eduardoarantes May 29, 2026
78df69f
feat(linear): collect issue history and convert to changelogs
eduardoarantes May 29, 2026
c8ab70d
test(linear): add blueprint v200 scope generation tests
eduardoarantes May 29, 2026
e59037f
docs(linear): add plugin README
eduardoarantes May 29, 2026
42440d8
fix(linear): guard lead-time fallback against resolution before creation
eduardoarantes May 29, 2026
8922686
fix(linear): map Linear triage state type to TODO
eduardoarantes May 29, 2026
b9f2861
refactor(linear): remove unused GraphqlInlineAccount struct
eduardoarantes May 29, 2026
2038be5
perf(linear): raise issue collector page size to 100
eduardoarantes May 29, 2026
28f23e5
feat(linear): populate issue assignee/creator names and issue_assignees
eduardoarantes May 29, 2026
33fa318
fix(linear): clear stale sprint_issues when issues leave their cycle
eduardoarantes May 29, 2026
698a036
feat(linear): derive issue lead time from state-transition history
eduardoarantes May 29, 2026
6fd5a68
feat(linear): add remote-scopes endpoints to enumerate teams
eduardoarantes May 29, 2026
46db27a
perf(linear): make comment and history collection incremental
eduardoarantes May 29, 2026
98fd010
fix(linear): filter issues server-side by updatedAt for incremental sync
eduardoarantes May 29, 2026
f76d398
refactor(linear): drop dead LeadTimeMinutes tool-layer field
eduardoarantes May 29, 2026
ad14037
feat(config-ui): register Linear plugin
eduardoarantes Jun 1, 2026
64fbd18
fix(config-ui): map Linear scope id to teamId
eduardoarantes Jun 1, 2026
4095d87
feat(linear): add Grafana dashboard
eduardoarantes Jun 1, 2026
adfbd25
fix(linear): convert team scope to a domain board
eduardoarantes Jun 1, 2026
da340e4
fix(linear): widen issue title/url columns to avoid truncation
eduardoarantes Jun 1, 2026
55dbfef
fix(linear): recover owning issue id for comments and history
eduardoarantes Jun 3, 2026
998f0e8
Merge branch 'main' into feat/linear-plugin
eduardoarantes Jun 3, 2026
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
115 changes: 115 additions & 0 deletions backend/plugins/linear/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<!--
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.
-->

# Linear

## Summary

This plugin collects data from [Linear](https://linear.app) through its
[GraphQL API](https://linear.app/developers/graphql) and maps it into DevLake's
standardized `ticket` domain, so Linear issues appear in DevLake dashboards
(throughput, lead/cycle time, sprint burndown, etc.).

The selectable **scope** is a Linear **Team**, which maps to a domain `Board`.

## Supported data

| Linear entity | Tool-layer table | Domain-layer table |
|-----------------|-----------------------------------|--------------------------------------------|
| Team | `_tool_linear_teams` (scope) | `boards` |
| User | `_tool_linear_accounts` | `accounts` |
| Workflow state | `_tool_linear_workflow_states` | (drives issue status mapping) |
| Issue | `_tool_linear_issues` | `issues`, `board_issues` |
| Label | `_tool_linear_issue_labels` | `issue_labels` |
| Comment | `_tool_linear_comments` | `issue_comments` |
| Cycle | `_tool_linear_cycles` | `sprints`, `board_sprints`, `sprint_issues`|
| Issue history | `_tool_linear_issue_history` | `issue_changelogs` |

### Field mapping highlights

- **Status** — derived deterministically from Linear's `WorkflowState.type`
(no manual mapping needed, unlike Jira):
- `backlog`, `unstarted` → `TODO`
- `started` → `IN_PROGRESS`
- `completed`, `canceled` → `DONE`
- **Priority** — Linear's integer priority maps to a label: `0` No priority,
`1` Urgent, `2` High, `3` Medium, `4` Low.
- **Type** — Linear has no native issue type, so issues default to `REQUIREMENT`.
- **Lead time** — `completedAt − createdAt` (Linear provides `startedAt`/`completedAt`
natively; the history changelog captures every status transition).
- **Story points** — Linear's `estimate`.

## Authentication

The plugin uses a Linear **personal API key**, passed verbatim in the
`Authorization` header (no `Bearer` prefix). Create one under
**Settings → Security & access → Personal API keys** in Linear.

## Configuration

Create a connection:

```
curl 'http://localhost:8080/api/plugins/linear/connections' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": "linear",
"endpoint": "https://api.linear.app/graphql",
"token": "<YOUR_LINEAR_API_KEY>",
"rateLimitPerHour": 1500
}'
```

Add a team scope (the team id is the Linear team UUID):

```
curl 'http://localhost:8080/api/plugins/linear/connections/<CONNECTION_ID>/scopes' \
--header 'Content-Type: application/json' \
--data-raw '{
"data": [{ "connectionId": <CONNECTION_ID>, "teamId": "<TEAM_ID>", "name": "Engineering" }]
}'
```

## Collecting data

```
curl 'http://localhost:8080/api/pipelines' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": "linear pipeline",
"plan": [[{
"plugin": "linear",
"options": { "connectionId": <CONNECTION_ID>, "teamId": "<TEAM_ID>" }
}]]
}'
```

## Rate limiting

Linear enforces a per-API-key request budget (1,500 requests/hour) plus a
complexity budget. The collector paces requests against the configured
`rateLimitPerHour` (default 1500). Issues are collected incrementally using
`updatedAt` ordering so re-runs only fetch changes.

## Limitations / roadmap

- Authentication is personal API key only; OAuth2 is a planned follow-up.
- Issue type defaults to `REQUIREMENT`; label-based type mapping via the scope
config is a planned follow-up.
- config-ui integration (connection form + team picker) and the website
documentation page are planned follow-ups; for now connections and scopes are
managed via the API calls shown above.
98 changes: 98 additions & 0 deletions backend/plugins/linear/api/blueprint_v200.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
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/linear/models"
"github.com/apache/incubator-devlake/plugins/linear/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.LinearTeam, models.LinearScopeConfig],
connection *models.LinearConnection,
) (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(
"linear",
subtaskMetas,
scopeConfig.Entities,
tasks.LinearOptions{
ConnectionId: connection.ID,
TeamId: scope.TeamId,
},
)
if err != nil {
return nil, err
}
stage = append(stage, task)
plan[i] = stage
}
return plan, nil
}

func makeScopesV200(
scopeDetails []*srvhelper.ScopeDetail[models.LinearTeam, models.LinearScopeConfig],
connection *models.LinearConnection,
) ([]plugin.Scope, errors.Error) {
scopes := make([]plugin.Scope, 0, len(scopeDetails))
idgen := didgen.NewDomainIdGenerator(&models.LinearTeam{})
for _, scopeDetail := range scopeDetails {
scope, scopeConfig := scopeDetail.Scope, scopeDetail.ScopeConfig
id := idgen.Generate(connection.ID, scope.TeamId)
if utils.StringsContains(scopeConfig.Entities, plugin.DOMAIN_TYPE_TICKET) {
scopes = append(scopes, ticket.NewBoard(id, scope.Name))
}
}
return scopes, nil
}
90 changes: 90 additions & 0 deletions backend/plugins/linear/api/blueprint_v200_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
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/apache/incubator-devlake/core/models/common"
"github.com/apache/incubator-devlake/core/plugin"
helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
"github.com/apache/incubator-devlake/helpers/srvhelper"
mockplugin "github.com/apache/incubator-devlake/mocks/core/plugin"
"github.com/apache/incubator-devlake/plugins/linear/models"
"github.com/stretchr/testify/assert"
)

func mockLinearPlugin(t *testing.T) {
mockMeta := mockplugin.NewPluginMeta(t)
mockMeta.On("RootPkgPath").Return("github.com/apache/incubator-devlake/plugins/linear")
mockMeta.On("Name").Return("linear").Maybe()
_ = plugin.RegisterPlugin("linear", mockMeta)
}

func TestMakeScopesV200(t *testing.T) {
mockLinearPlugin(t)

const connectionId uint64 = 1
const teamId = "team-1"
const expectDomainScopeId = "linear:LinearTeam:1:team-1"

scopes, err := makeScopesV200(
[]*srvhelper.ScopeDetail[models.LinearTeam, models.LinearScopeConfig]{
{
Scope: models.LinearTeam{
Scope: common.Scope{ConnectionId: connectionId},
TeamId: teamId,
Name: "Engineering",
},
ScopeConfig: &models.LinearScopeConfig{
ScopeConfig: common.ScopeConfig{Entities: []string{plugin.DOMAIN_TYPE_TICKET}},
},
},
},
&models.LinearConnection{
BaseConnection: helper.BaseConnection{Model: common.Model{ID: connectionId}},
},
)
assert.Nil(t, err)
assert.Equal(t, 1, len(scopes))
assert.Equal(t, expectDomainScopeId, scopes[0].ScopeId())
}

func TestMakeScopesV200WithoutTicketEntity(t *testing.T) {
mockLinearPlugin(t)

scopes, err := makeScopesV200(
[]*srvhelper.ScopeDetail[models.LinearTeam, models.LinearScopeConfig]{
{
Scope: models.LinearTeam{
Scope: common.Scope{ConnectionId: 1},
TeamId: "team-1",
},
ScopeConfig: &models.LinearScopeConfig{
ScopeConfig: common.ScopeConfig{Entities: []string{}},
},
},
},
&models.LinearConnection{
BaseConnection: helper.BaseConnection{Model: common.Model{ID: 1}},
},
)
assert.Nil(t, err)
// no ticket entity selected => no domain board scope produced
assert.Equal(t, 0, len(scopes))
}
Loading