Skip to content

Commit cf7c14e

Browse files
authored
feat(webhook): add support for webhook PRs (#8435) (#8464)
* feat(webhook): add support for webhook PRs (#8435) * feat: pr webhook * feat: pr webhook * feat: finish PR Webhook and Frontend Updates * fix: cleanup and comments * fix: go deps * fix: base_repo_id static for webhook fix: ui * chore: fix gocsv version change
1 parent 41c047d commit cf7c14e

File tree

11 files changed

+254
-10
lines changed

11 files changed

+254
-10
lines changed

backend/go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,6 @@ require (
129129
golang.org/x/mod v0.17.0
130130
)
131131

132+
replace github.com/chenzhuoyu/iasm => github.com/cloudwego/iasm v0.2.0
133+
132134
//replace github.com/apache/incubator-devlake => ./

backend/go.sum

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
8080
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
8181
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
8282
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
83+
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
8384
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
8485
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
8586
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=

backend/plugins/webhook/api/blueprint_v200.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/apache/incubator-devlake/core/errors"
2424
coreModels "github.com/apache/incubator-devlake/core/models"
2525
"github.com/apache/incubator-devlake/core/models/domainlayer"
26+
"github.com/apache/incubator-devlake/core/models/domainlayer/code"
2627
"github.com/apache/incubator-devlake/core/models/domainlayer/devops"
2728
"github.com/apache/incubator-devlake/core/models/domainlayer/ticket"
2829
"github.com/apache/incubator-devlake/core/plugin"
@@ -54,5 +55,13 @@ func MakeDataSourcePipelinePlanV200(connectionId uint64) (coreModels.PipelinePla
5455
Name: connection.Name,
5556
})
5657

58+
// add repos to scopes
59+
scopes = append(scopes, &code.Repo{
60+
DomainEntity: domainlayer.DomainEntity{
61+
Id: fmt.Sprintf("%s:%d", "webhook", connection.ID),
62+
},
63+
Name: connection.Name,
64+
})
65+
5766
return nil, scopes, nil
5867
}

backend/plugins/webhook/api/connection.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ type WebhookConnectionResponse struct {
184184
models.WebhookConnection
185185
PostIssuesEndpoint string `json:"postIssuesEndpoint"`
186186
CloseIssuesEndpoint string `json:"closeIssuesEndpoint"`
187+
PostPullRequestsEndpoint string `json:"postPullRequestsEndpoint"`
187188
PostPipelineTaskEndpoint string `json:"postPipelineTaskEndpoint"`
188189
PostPipelineDeployTaskEndpoint string `json:"postPipelineDeployTaskEndpoint"`
189190
ClosePipelineEndpoint string `json:"closePipelineEndpoint"`
@@ -256,6 +257,7 @@ func formatConnection(connection *models.WebhookConnection, withApiKeyInfo bool)
256257
response := &WebhookConnectionResponse{WebhookConnection: *connection}
257258
response.PostIssuesEndpoint = fmt.Sprintf(`/rest/plugins/webhook/connections/%d/issues`, connection.ID)
258259
response.CloseIssuesEndpoint = fmt.Sprintf(`/rest/plugins/webhook/connections/%d/issue/:issueKey/close`, connection.ID)
260+
response.PostPullRequestsEndpoint = fmt.Sprintf(`/rest/plugins/webhook/connections/%d/pull_requests`, connection.ID)
259261
response.PostPipelineTaskEndpoint = fmt.Sprintf(`/rest/plugins/webhook/connections/%d/cicd_tasks`, connection.ID)
260262
response.PostPipelineDeployTaskEndpoint = fmt.Sprintf(`/rest/plugins/webhook/connections/%d/deployments`, connection.ID)
261263
response.ClosePipelineEndpoint = fmt.Sprintf(`/rest/plugins/webhook/connections/%d/cicd_pipeline/:pipelineName/finish`, connection.ID)
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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+
"fmt"
22+
"net/http"
23+
"time"
24+
25+
"github.com/apache/incubator-devlake/core/dal"
26+
"github.com/apache/incubator-devlake/core/log"
27+
28+
"github.com/apache/incubator-devlake/helpers/dbhelper"
29+
"github.com/go-playground/validator/v10"
30+
31+
"github.com/apache/incubator-devlake/core/errors"
32+
"github.com/apache/incubator-devlake/core/models/domainlayer"
33+
"github.com/apache/incubator-devlake/core/models/domainlayer/code"
34+
"github.com/apache/incubator-devlake/core/plugin"
35+
"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
36+
"github.com/apache/incubator-devlake/plugins/webhook/models"
37+
)
38+
39+
type WebhookPullRequestReq struct {
40+
Id string `mapstructure:"id" validate:"required"`
41+
BaseRepoId string `mapstructure:"baseRepoId"`
42+
HeadRepoId string `mapstructure:"headRepoId"`
43+
Status string `mapstructure:"status" validate:"omitempty,oneof=OPEN CLOSED MERGED"`
44+
OriginalStatus string `mapstructure:"originalStatus"`
45+
Title string `mapstructure:"displayTitle" validate:"required"`
46+
Description string `mapstructure:"description"`
47+
Url string `mapstructure:"url"`
48+
AuthorName string `mapstructure:"authorName"`
49+
AuthorId string `mapstructure:"authorId"`
50+
MergedByName string `mapstructure:"mergedByName"`
51+
MergedById string `mapstructure:"mergedById"`
52+
ParentPrId string `mapstructure:"parentPrId"`
53+
PullRequestKey int `mapstructure:"pullRequestKey" validate:"required"`
54+
CreatedDate time.Time `mapstructure:"createdDate" validate:"required"`
55+
MergedDate *time.Time `mapstructure:"mergedDate"`
56+
ClosedDate *time.Time `mapstructure:"closedDate"`
57+
Type string `mapstructure:"type"`
58+
Component string `mapstructure:"component"`
59+
MergeCommitSha string `mapstructure:"mergeCommitSha"`
60+
HeadRef string `mapstructure:"headRef"`
61+
BaseRef string `mapstructure:"baseRef"`
62+
BaseCommitSha string `mapstructure:"baseCommitSha"`
63+
HeadCommitSha string `mapstructure:"headCommitSha"`
64+
Additions int `mapstructure:"additions"`
65+
Deletions int `mapstructure:"deletions"`
66+
IsDraft bool `mapstructure:"isDraft"`
67+
}
68+
69+
// PostPullRequests
70+
// @Summary create pull requests by webhook
71+
// @Description Create pull request by webhook.<br/>
72+
// @Description example1: {"id": "pr1","baseRepoId": "webhook:1","headRepoId": "repo_fork1","status": "MERGED","originalStatus": "OPEN","displayTitle": "Feature: Add new functionality","description": "This PR adds new features","url": "https://github.com/org/repo/pull/1","authorName": "johndoe","authorId": "johnd123","mergedByName": "janedoe","mergedById": "janed123","parentPrId": "","pullRequestKey": 1,"createdDate": "2025-02-20T16:17:36Z","mergedDate": "2025-02-20T17:17:36Z","closedDate": null,"type": "feature","component": "backend","mergeCommitSha": "bf0a79c57dff8f5f1f393de315ee5105a535e059","headRef": "repo_fork1:feature-branch","baseRef": "main","baseCommitSha": "e73325c2c9863f42ea25871cbfaeebcb8edcf604","headCommitSha": "b22f772f1197edfafd4cc5fe679a2d299ec12837","additions": 100,"deletions": 50,"isDraft": false}<br/>
73+
// @Description "baseRepoId" must be equal to "webhook:{connectionId}" for this to work correctly and calculate DORA metrics
74+
// @Tags plugins/webhook
75+
// @Param body body WebhookPullRequestReq true "json body"
76+
// @Success 200
77+
// @Failure 400 {string} errcode.Error "Bad Request"
78+
// @Failure 403 {string} errcode.Error "Forbidden"
79+
// @Failure 500 {string} errcode.Error "Internal Error"
80+
// @Router /plugins/webhook/connections/:connectionId/pullrequests [POST]
81+
func PostPullRequests(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
82+
connection := &models.WebhookConnection{}
83+
err := connectionHelper.First(connection, input.Params)
84+
85+
return postPullRequests(input, connection, err)
86+
}
87+
88+
// PostPullRequestsByName
89+
// @Summary create pull requests by webhook name
90+
// @Description Create pull request by webhook name.<br/>
91+
// @Description example1: {"id": "pr1","baseRepoId": "webhook:1","headRepoId": "repo_fork1","status": "MERGED","originalStatus": "OPEN","displayTitle": "Feature: Add new functionality","description": "This PR adds new features","url": "https://github.com/org/repo/pull/1","authorName": "johndoe","authorId": "johnd123","mergedByName": "janedoe","mergedById": "janed123","parentPrId": "","pullRequestKey": 1,"createdDate": "2025-02-20T16:17:36Z","mergedDate": "2025-02-20T17:17:36Z","closedDate": null,"type": "feature","component": "backend","mergeCommitSha": "bf0a79c57dff8f5f1f393de315ee5105a535e059","headRef": "repo_fork1:feature-branch","baseRef": "main","baseCommitSha": "e73325c2c9863f42ea25871cbfaeebcb8edcf604","headCommitSha": "b22f772f1197edfafd4cc5fe679a2d299ec12837","additions": 100,"deletions": 50,"isDraft": false}<br/>
92+
// @Description "baseRepoId" must be equal to "webhook:{connectionId}" for this to work correctly and calculate DORA metrics
93+
// @Tags plugins/webhook
94+
// @Param body body WebhookPullRequestReq true "json body"
95+
// @Success 200
96+
// @Failure 400 {string} errcode.Error "Bad Request"
97+
// @Failure 403 {string} errcode.Error "Forbidden"
98+
// @Failure 500 {string} errcode.Error "Internal Error"
99+
// @Router /plugins/webhook/connections/by-name/:connectionName/pullrequests [POST]
100+
func PostPullRequestsByName(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) {
101+
connection := &models.WebhookConnection{}
102+
err := connectionHelper.FirstByName(connection, input.Params)
103+
104+
return postPullRequests(input, connection, err)
105+
}
106+
107+
func postPullRequests(input *plugin.ApiResourceInput, connection *models.WebhookConnection, err errors.Error) (*plugin.ApiResourceOutput, errors.Error) {
108+
if err != nil {
109+
return nil, err
110+
}
111+
// get request
112+
request := &WebhookPullRequestReq{}
113+
err = api.DecodeMapStruct(input.Body, request, true)
114+
if err != nil {
115+
return &plugin.ApiResourceOutput{Body: err.Error(), Status: http.StatusBadRequest}, nil
116+
}
117+
// validate
118+
vld = validator.New()
119+
err = errors.Convert(vld.Struct(request))
120+
if err != nil {
121+
return nil, errors.BadInput.Wrap(vld.Struct(request), `input json error`)
122+
}
123+
txHelper := dbhelper.NewTxHelper(basicRes, &err)
124+
defer txHelper.End()
125+
tx := txHelper.Begin()
126+
if err := CreatePullRequest(connection, request, tx, logger); err != nil {
127+
logger.Error(err, "create pull requests")
128+
return nil, err
129+
}
130+
131+
return &plugin.ApiResourceOutput{Body: nil, Status: http.StatusOK}, nil
132+
}
133+
134+
func CreatePullRequest(connection *models.WebhookConnection, request *WebhookPullRequestReq, tx dal.Transaction, logger log.Logger) errors.Error {
135+
// validation
136+
if request == nil {
137+
return errors.BadInput.New("request body is nil")
138+
}
139+
// create a pull_request record
140+
pullRequest := &code.PullRequest{
141+
DomainEntity: domainlayer.DomainEntity{
142+
Id: fmt.Sprintf("%s:%d:%d", "webhook", connection.ID, request.PullRequestKey),
143+
},
144+
BaseRepoId: fmt.Sprintf("%s:%d", "webhook", connection.ID),
145+
HeadRepoId: request.HeadRepoId,
146+
Status: request.Status,
147+
OriginalStatus: request.OriginalStatus,
148+
Title: request.Title,
149+
Description: request.Description,
150+
Url: request.Url,
151+
AuthorName: request.AuthorName,
152+
AuthorId: request.AuthorId,
153+
MergedByName: request.MergedByName,
154+
MergedById: request.MergedById,
155+
ParentPrId: request.ParentPrId,
156+
PullRequestKey: request.PullRequestKey,
157+
CreatedDate: request.CreatedDate,
158+
MergedDate: request.MergedDate,
159+
ClosedDate: request.ClosedDate,
160+
Type: request.Type,
161+
Component: request.Component,
162+
MergeCommitSha: request.MergeCommitSha,
163+
HeadRef: request.HeadRef,
164+
BaseRef: request.BaseRef,
165+
BaseCommitSha: request.BaseCommitSha,
166+
HeadCommitSha: request.HeadCommitSha,
167+
Additions: request.Additions,
168+
Deletions: request.Deletions,
169+
IsDraft: request.IsDraft,
170+
}
171+
if err := tx.CreateOrUpdate(pullRequest); err != nil {
172+
logger.Error(err, "failed to save pull request")
173+
return err
174+
}
175+
return nil
176+
}

backend/plugins/webhook/impl/impl.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ func (p Webhook) ApiResources() map[string]map[string]plugin.ApiResourceHandler
9090
"connections/:connectionId/deployments": {
9191
"POST": api.PostDeployments,
9292
},
93+
"connections/:connectionId/pull_requests": {
94+
"POST": api.PostPullRequests,
95+
},
9396
"connections/:connectionId/issues": {
9497
"POST": api.PostIssue,
9598
},
@@ -99,6 +102,9 @@ func (p Webhook) ApiResources() map[string]map[string]plugin.ApiResourceHandler
99102
":connectionId/deployments": {
100103
"POST": api.PostDeployments,
101104
},
105+
":connectionId/pull_requests": {
106+
"POST": api.PostPullRequests,
107+
},
102108
":connectionId/issues": {
103109
"POST": api.PostIssue,
104110
},
@@ -113,6 +119,9 @@ func (p Webhook) ApiResources() map[string]map[string]plugin.ApiResourceHandler
113119
"connections/by-name/:connectionName/deployments": {
114120
"POST": api.PostDeploymentsByName,
115121
},
122+
"connections/by-name/:connectionName/pull_requests": {
123+
"POST": api.PostPullRequestsByName,
124+
},
116125
"connections/by-name/:connectionName/issues": {
117126
"POST": api.PostIssueByName,
118127
},

config-ui/src/features/connections/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export const transformWebhook = (connection: IWebhookAPI): IWebhook => {
5050
postIssuesEndpoint: connection.postIssuesEndpoint,
5151
closeIssuesEndpoint: connection.closeIssuesEndpoint,
5252
postPipelineDeployTaskEndpoint: connection.postPipelineDeployTaskEndpoint,
53+
postPullRequestsEndpoint: connection.postPullRequestsEndpoint,
5354
apiKeyId: connection.apiKey.id,
5455
};
5556
};

config-ui/src/plugins/register/webhook/components/create-dialog.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export const CreateDialog = ({ open, onCancel, onSubmitAfter }: Props) => {
4444
postIssuesEndpoint: '',
4545
closeIssuesEndpoint: '',
4646
postDeploymentsCurl: '',
47+
postPullRequestsEndpoint: '',
4748
apiKey: '',
4849
});
4950

@@ -55,7 +56,7 @@ export const CreateDialog = ({ open, onCancel, onSubmitAfter }: Props) => {
5556
const [success, res] = await operator(
5657
async () => {
5758
const {
58-
webhook: { id, postIssuesEndpoint, closeIssuesEndpoint, postPipelineDeployTaskEndpoint },
59+
webhook: { id, postIssuesEndpoint, closeIssuesEndpoint, postPipelineDeployTaskEndpoint, postPullRequestsEndpoint },
5960
apiKey,
6061
} = await dispatch(addWebhook({ name })).unwrap();
6162

@@ -65,6 +66,7 @@ export const CreateDialog = ({ open, onCancel, onSubmitAfter }: Props) => {
6566
postIssuesEndpoint,
6667
closeIssuesEndpoint,
6768
postPipelineDeployTaskEndpoint,
69+
postPullRequestsEndpoint,
6870
};
6971
},
7072
{
@@ -151,6 +153,17 @@ export const CreateDialog = ({ open, onCancel, onSubmitAfter }: Props) => {
151153
.
152154
</p>
153155
</Block>
156+
<Block title="Pull Requests">
157+
<h5>Post to register a pull request</h5>
158+
<CopyText content={record.postPullRequestsEndpoint} />
159+
<p>
160+
See the{' '}
161+
<ExternalLink link="https://devlake.apache.org/docs/Plugins/webhook#pull_requests">
162+
full payload schema
163+
</ExternalLink>
164+
.
165+
</p>
166+
</Block>
154167
</S.Wrapper>
155168
)}
156169
</Modal>

config-ui/src/plugins/register/webhook/components/utils.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,8 @@ import { IWebhook } from '@/types';
2020

2121
export const transformURI = (prefix: string, webhook: IWebhook, apiKey: string) => {
2222
return {
23-
postIssuesEndpoint: `curl ${prefix}${webhook.postIssuesEndpoint} -X 'POST' -H 'Authorization: Bearer ${
24-
apiKey ?? '{API_KEY}'
25-
}' -d '{
23+
postIssuesEndpoint: `curl ${prefix}${webhook.postIssuesEndpoint} -X 'POST' -H 'Authorization: Bearer ${apiKey ?? '{API_KEY}'
24+
}' -d '{
2625
"issueKey":"DLK-1234",
2726
"title":"an incident from DLK",
2827
"type":"INCIDENT",
@@ -31,12 +30,10 @@ export const transformURI = (prefix: string, webhook: IWebhook, apiKey: string)
3130
"createdDate":"2020-01-01T12:00:00+00:00",
3231
"updatedDate":"2020-01-01T12:00:00+00:00"
3332
}'`,
34-
closeIssuesEndpoint: `curl ${prefix}${webhook.closeIssuesEndpoint} -X 'POST' -H 'Authorization: Bearer ${
35-
apiKey ?? '{API_KEY}'
36-
}'`,
37-
postDeploymentsCurl: `curl ${prefix}${webhook.postPipelineDeployTaskEndpoint} -X 'POST' -H 'Authorization: Bearer ${
38-
apiKey ?? '{API_KEY}'
39-
}' -d '{
33+
closeIssuesEndpoint: `curl ${prefix}${webhook.closeIssuesEndpoint} -X 'POST' -H 'Authorization: Bearer ${apiKey ?? '{API_KEY}'
34+
}'`,
35+
postDeploymentsCurl: `curl ${prefix}${webhook.postPipelineDeployTaskEndpoint} -X 'POST' -H 'Authorization: Bearer ${apiKey ?? '{API_KEY}'
36+
}' -d '{
4037
"id": "Required. This will be the unique ID of the deployment",
4138
"startedDate": "2023-01-01T12:00:00+00:00",
4239
"finishedDate": "2023-01-01T12:00:00+00:00",
@@ -52,5 +49,26 @@ export const transformURI = (prefix: string, webhook: IWebhook, apiKey: string)
5249
}
5350
]
5451
}'`,
52+
postPullRequestsEndpoint: `curl ${prefix}${webhook.postPullRequestsEndpoint} -X 'POST' -H 'Authorization: Bearer ${apiKey ?? '{API_KEY}'
53+
}' -d '{
54+
"id": "Required. This will be the unique ID of the pull request",
55+
"baseRepoId": "your-repo-id",
56+
"headRepoId": "your-repo-id",
57+
"status": "MERGED",
58+
"originalStatus": "OPEN",
59+
"displayTitle": "Feature: Add new functionality",
60+
"description": "This PR adds new features",
61+
"url": "https://github.com/org/repo/pull/1",
62+
"pullRequestKey": 1,
63+
"createdDate": "2025-02-20T16:17:36Z",
64+
"mergedDate": "2025-02-20T17:17:36Z",
65+
"closedDate": null,
66+
"mergeCommitSha": "bf0a79c57dff8f5f1f393de315ee5105a535e059",
67+
"headRef": "your-branch-name",
68+
"baseRef": "main",
69+
"baseCommitSha": "e73325c2c9863f42ea25871cbfaeebcb8edcf604",
70+
"headCommitSha": "b22f772f1197edfafd4cc5fe679a2d299ec12837",
71+
"isDraft": false
72+
}`,
5573
};
5674
};

config-ui/src/plugins/register/webhook/components/view-dialog.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,17 @@ export const ViewDialog = ({ initialId, onCancel }: Props) => {
9494
.
9595
</p>
9696
</Block>
97+
<Block title="Pull Requests">
98+
<h5>Post to register/update a pull_request</h5>
99+
<CopyText content={URI.postPullRequestsEndpoint} />
100+
<p>
101+
See the{' '}
102+
<ExternalLink link="https://devlake.apache.org/docs/Plugins/webhook#pull_requests">
103+
full payload schema
104+
</ExternalLink>
105+
.
106+
</p>
107+
</Block>
97108
<Block
98109
title="API Key"
99110
description="If you have forgotten your API key, you can revoke the previous key and generate a new one as a replacement."

0 commit comments

Comments
 (0)