Skip to content

Commit 3f92cfd

Browse files
committed
feat(plugins): add config-ui to slack
This change implements the frontend UI configuration for the Slack plugin, enabling users to set up Slack connections through the DevLake dashboard interface. Previously, the Slack plugin existed only as a backend implementation without any user-facing configuration interface. The implementation adds: - Connection setup form with fields for name, endpoint URL, Slack bot token, and rate limiting - Channel selection interface supporting both public and private channels based on bot permissions - Integration with existing backend APIs for connection testing and scope management - Consistent UX patterns matching other DevLake plugins (GitHub, Jira, etc.) This enables users to configure Slack data collection through the standard DevLake workflow instead of requiring manual API calls or configuration file editing. #8555
1 parent c33efe8 commit 3f92cfd

File tree

20 files changed

+649
-52
lines changed

20 files changed

+649
-52
lines changed
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package api
19+
20+
import (
21+
"github.com/apache/incubator-devlake/core/errors"
22+
coreModels "github.com/apache/incubator-devlake/core/models"
23+
"github.com/apache/incubator-devlake/core/plugin"
24+
helperapi "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
25+
"github.com/apache/incubator-devlake/plugins/slack/tasks"
26+
)
27+
28+
func MakeDataSourcePipelinePlanV200(
29+
subtaskMetas []plugin.SubTaskMeta,
30+
connectionId uint64,
31+
bpScopes []*coreModels.BlueprintScope,
32+
) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) {
33+
// Map blueprint scopes to actual Slack channels
34+
scopeDetails, err := dsHelper.ScopeSrv.MapScopeDetails(connectionId, bpScopes)
35+
if err != nil {
36+
return nil, nil, err
37+
}
38+
// Build one stage per selected channel
39+
plan := make(coreModels.PipelinePlan, len(scopeDetails))
40+
for i, scopeDetail := range scopeDetails {
41+
stage := plan[i]
42+
if stage == nil {
43+
stage = coreModels.PipelineStage{}
44+
}
45+
// Only include CROSS domain subtasks; Slack subtasks define DomainTypes accordingly.
46+
entities := []string{plugin.DOMAIN_TYPE_CROSS}
47+
scope := scopeDetail.Scope // *models.SlackChannel
48+
task, err := helperapi.MakePipelinePlanTask(
49+
"slack",
50+
subtaskMetas,
51+
entities,
52+
tasks.SlackOptions{ConnectionId: connectionId, ChannelId: scope.ScopeId()},
53+
)
54+
if err != nil {
55+
return nil, nil, err
56+
}
57+
stage = append(stage, task)
58+
plan[i] = stage
59+
}
60+
// No domain scopes emitted by Slack for now
61+
return plan, nil, nil
62+
}

backend/plugins/slack/api/init.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,18 @@ import (
2121
"github.com/apache/incubator-devlake/core/context"
2222
"github.com/apache/incubator-devlake/core/plugin"
2323
"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
24+
"github.com/apache/incubator-devlake/helpers/srvhelper"
25+
"github.com/apache/incubator-devlake/plugins/slack/models"
2426
"github.com/go-playground/validator/v10"
2527
)
2628

2729
var vld *validator.Validate
2830
var connectionHelper *api.ConnectionApiHelper
2931
var basicRes context.BasicRes
32+
var dsHelper *api.DsHelper[models.SlackConnection, models.SlackChannel, srvhelper.NoScopeConfig]
33+
var raProxy *api.DsRemoteApiProxyHelper[models.SlackConnection]
34+
var raScopeList *api.DsRemoteApiScopeListHelper[models.SlackConnection, models.SlackChannel, SlackRemotePagination]
35+
var raScopeSearch *api.DsRemoteApiScopeSearchHelper[models.SlackConnection, models.SlackChannel]
3036

3137
func Init(br context.BasicRes, p plugin.PluginMeta) {
3238

@@ -37,4 +43,18 @@ func Init(br context.BasicRes, p plugin.PluginMeta) {
3743
vld,
3844
p.Name(),
3945
)
46+
47+
dsHelper = api.NewDataSourceHelper[
48+
models.SlackConnection, models.SlackChannel, srvhelper.NoScopeConfig,
49+
](
50+
br,
51+
p.Name(),
52+
[]string{"name"},
53+
func(c models.SlackConnection) models.SlackConnection { return c.Sanitize() },
54+
func(s models.SlackChannel) models.SlackChannel { return s },
55+
nil,
56+
)
57+
raProxy = api.NewDsRemoteApiProxyHelper[models.SlackConnection](dsHelper.ConnApi.ModelApiHelper)
58+
raScopeList = api.NewDsRemoteApiScopeListHelper[models.SlackConnection, models.SlackChannel, SlackRemotePagination](raProxy, listSlackRemoteScopes)
59+
raScopeSearch = api.NewDsRemoteApiScopeSearchHelper[models.SlackConnection, models.SlackChannel](raProxy, searchSlackRemoteScopes)
4060
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package api
19+
20+
import (
21+
"encoding/json"
22+
"net/url"
23+
"strconv"
24+
"strings"
25+
26+
"github.com/apache/incubator-devlake/core/errors"
27+
"github.com/apache/incubator-devlake/core/plugin"
28+
helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
29+
dsmodels "github.com/apache/incubator-devlake/helpers/pluginhelper/api/models"
30+
"github.com/apache/incubator-devlake/plugins/slack/models"
31+
)
32+
33+
type SlackRemotePagination struct {
34+
Cursor string `json:"cursor"`
35+
Limit int `json:"limit"`
36+
}
37+
38+
type slackConvListResp struct {
39+
Ok bool `json:"ok"`
40+
Error string `json:"error"`
41+
Needed string `json:"needed"`
42+
Provided string `json:"provided"`
43+
Channels []json.RawMessage `json:"channels"`
44+
ResponseMetadata struct {
45+
NextCursor string `json:"next_cursor"`
46+
} `json:"response_metadata"`
47+
}
48+
49+
func listSlackRemoteScopes(
50+
_ *models.SlackConnection,
51+
apiClient plugin.ApiClient,
52+
_ string,
53+
page SlackRemotePagination,
54+
) (
55+
children []dsmodels.DsRemoteApiScopeListEntry[models.SlackChannel],
56+
nextPage *SlackRemotePagination,
57+
err errors.Error,
58+
) {
59+
if page.Limit == 0 {
60+
page.Limit = 100
61+
}
62+
// helper to perform API call with given query
63+
call := func(q url.Values) (*slackConvListResp, errors.Error) {
64+
res, e := apiClient.Get("conversations.list", q, nil)
65+
if e != nil {
66+
return nil, e
67+
}
68+
resp := &slackConvListResp{}
69+
if e = helper.UnmarshalResponse(res, resp); e != nil {
70+
return nil, e
71+
}
72+
return resp, nil
73+
}
74+
75+
q := url.Values{}
76+
q.Set("limit", strconv.Itoa(page.Limit))
77+
if page.Cursor != "" {
78+
q.Set("cursor", page.Cursor)
79+
}
80+
81+
q.Set("types", "public_channel,private_channel")
82+
resp, e := call(q)
83+
if e != nil {
84+
err = e
85+
return
86+
}
87+
// handle missing_scope gracefully by retrying with private_channel only if channels:read is missing
88+
if !resp.Ok && resp.Error == "missing_scope" {
89+
if strings.Contains(resp.Needed, "channels:read") {
90+
// retry with private channels only (requires groups:read)
91+
q.Set("types", "private_channel")
92+
resp, e = call(q)
93+
if e != nil {
94+
err = e
95+
return
96+
}
97+
}
98+
}
99+
if !resp.Ok {
100+
err = errors.BadInput.New("slack conversations.list error: " + resp.Error)
101+
return
102+
}
103+
104+
for _, raw := range resp.Channels {
105+
var ch models.SlackChannel
106+
if e := errors.Convert(json.Unmarshal(raw, &ch)); e != nil {
107+
err = e
108+
return
109+
}
110+
children = append(children, dsmodels.DsRemoteApiScopeListEntry[models.SlackChannel]{
111+
Type: helper.RAS_ENTRY_TYPE_SCOPE,
112+
Id: ch.Id,
113+
Name: ch.Name,
114+
FullName: ch.Name,
115+
Data: &ch,
116+
})
117+
}
118+
if resp.ResponseMetadata.NextCursor != "" {
119+
nextPage = &SlackRemotePagination{Cursor: resp.ResponseMetadata.NextCursor, Limit: page.Limit}
120+
}
121+
return
122+
}
123+
124+
func searchSlackRemoteScopes(
125+
apiClient plugin.ApiClient,
126+
params *dsmodels.DsRemoteApiScopeSearchParams,
127+
) (
128+
children []dsmodels.DsRemoteApiScopeListEntry[models.SlackChannel],
129+
err errors.Error,
130+
) {
131+
cursor := ""
132+
remaining := params.PageSize
133+
for remaining > 0 {
134+
list, next, e := listSlackRemoteScopes(nil, apiClient, "", SlackRemotePagination{Cursor: cursor, Limit: 200})
135+
if e != nil {
136+
err = e
137+
return
138+
}
139+
for _, it := range list {
140+
if params.Search == "" || (it.Name != "" && strings.Contains(strings.ToLower(it.Name), strings.ToLower(params.Search))) {
141+
children = append(children, it)
142+
remaining--
143+
if remaining == 0 {
144+
return
145+
}
146+
}
147+
}
148+
if next == nil || next.Cursor == "" {
149+
break
150+
}
151+
cursor = next.Cursor
152+
}
153+
return
154+
}
155+
156+
func RemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
157+
return raScopeList.Get(input)
158+
}
159+
160+
func SearchRemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
161+
return raScopeSearch.Get(input)
162+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package api
19+
20+
import (
21+
"github.com/apache/incubator-devlake/core/errors"
22+
"github.com/apache/incubator-devlake/core/plugin"
23+
"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
24+
"github.com/apache/incubator-devlake/helpers/srvhelper"
25+
"github.com/apache/incubator-devlake/plugins/slack/models"
26+
)
27+
28+
type PutScopesReqBody api.PutScopesReqBody[models.SlackChannel]
29+
type ScopeDetail srvhelper.ScopeDetail[models.SlackChannel, srvhelper.NoScopeConfig]
30+
31+
// PutScopes create or update slack channels (scopes)
32+
// @Summary create or update Slack channels
33+
// @Description Create or update Slack channels
34+
// @Tags plugins/slack
35+
// @Accept application/json
36+
// @Param connectionId path int false "connection ID"
37+
// @Param scope body PutScopesReqBody true "json"
38+
// @Success 200 {object} []models.SlackChannel
39+
// @Failure 400 {object} shared.ApiBody "Bad Request"
40+
// @Failure 500 {object} shared.ApiBody "Internal Error"
41+
// @Router /plugins/slack/connections/{connectionId}/scopes [PUT]
42+
func PutScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
43+
return dsHelper.ScopeApi.PutMultiple(input)
44+
}
45+
46+
// GetScopeList get Slack channels
47+
// @Summary get Slack channels
48+
// @Description get Slack channels
49+
// @Tags plugins/slack
50+
// @Param connectionId path int false "connection ID"
51+
// @Param pageSize query int false "page size, default 50"
52+
// @Param page query int false "page size, default 1"
53+
// @Param blueprints query bool false "also return blueprints using these scopes as part of the payload"
54+
// @Success 200 {object} []ScopeDetail
55+
// @Failure 400 {object} shared.ApiBody "Bad Request"
56+
// @Failure 500 {object} shared.ApiBody "Internal Error"
57+
// @Router /plugins/slack/connections/{connectionId}/scopes [GET]
58+
func GetScopeList(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
59+
return dsHelper.ScopeApi.GetPage(input)
60+
}
61+
62+
// GetScope get one Slack channel
63+
// @Summary get one Slack channel
64+
// @Description get one Slack channel
65+
// @Tags plugins/slack
66+
// @Param connectionId path int false "connection ID"
67+
// @Param scopeId path string false "channel id"
68+
// @Param blueprints query bool false "also return blueprints using this scope as part of the payload"
69+
// @Success 200 {object} ScopeDetail
70+
// @Failure 400 {object} shared.ApiBody "Bad Request"
71+
// @Failure 500 {object} shared.ApiBody "Internal Error"
72+
// @Router /plugins/slack/connections/{connectionId}/scopes/{scopeId} [GET]
73+
func GetScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
74+
return dsHelper.ScopeApi.GetScopeDetail(input)
75+
}
76+
77+
// PatchScope patch a Slack channel
78+
// @Summary patch a Slack channel
79+
// @Description patch a Slack channel
80+
// @Tags plugins/slack
81+
// @Accept application/json
82+
// @Param connectionId path int false "connection ID"
83+
// @Param scopeId path string false "channel id"
84+
// @Param scope body models.SlackChannel true "json"
85+
// @Success 200 {object} models.SlackChannel
86+
// @Failure 400 {object} shared.ApiBody "Bad Request"
87+
// @Failure 500 {object} shared.ApiBody "Internal Error"
88+
// @Router /plugins/slack/connections/{connectionId}/scopes/{scopeId} [PATCH]
89+
func PatchScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
90+
return dsHelper.ScopeApi.Patch(input)
91+
}
92+
93+
// DeleteScope delete plugin data associated with the scope and optionally the scope itself
94+
// @Summary delete plugin data associated with the scope and optionally the scope itself
95+
// @Description delete data associated with plugin scope
96+
// @Tags plugins/slack
97+
// @Param connectionId path int true "connection ID"
98+
// @Param scopeId path string true "channel id"
99+
// @Param delete_data_only query bool false "Only delete the scope data, not the scope itself"
100+
// @Success 200
101+
// @Failure 400 {object} shared.ApiBody "Bad Request"
102+
// @Failure 409 {object} srvhelper.DsRefs "References exist to this scope"
103+
// @Failure 500 {object} shared.ApiBody "Internal Error"
104+
// @Router /plugins/slack/connections/{connectionId}/scopes/{scopeId} [DELETE]
105+
func DeleteScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
106+
return dsHelper.ScopeApi.Delete(input)
107+
}

0 commit comments

Comments
 (0)