This is an automated email from the ASF dual-hosted git repository. klesh pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/incubator-devlake.git
The following commit(s) were added to refs/heads/main by this push: new cf7c14ed9 feat(webhook): add support for webhook PRs (#8435) (#8464) cf7c14ed9 is described below commit cf7c14ed9eab99e5d002cdb12742a48ac1c9e32c Author: Zach Jacobson <72418868+zjac...@users.noreply.github.com> AuthorDate: Sun Jun 15 20:07:59 2025 -0700 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 --- backend/go.mod | 2 + backend/go.sum | 1 + backend/plugins/webhook/api/blueprint_v200.go | 9 ++ backend/plugins/webhook/api/connection.go | 2 + backend/plugins/webhook/api/pull_requests.go | 176 +++++++++++++++++++++ backend/plugins/webhook/impl/impl.go | 9 ++ config-ui/src/features/connections/utils.ts | 1 + .../register/webhook/components/create-dialog.tsx | 15 +- .../plugins/register/webhook/components/utils.ts | 36 +++-- .../register/webhook/components/view-dialog.tsx | 11 ++ config-ui/src/types/webhook.ts | 2 + 11 files changed, 254 insertions(+), 10 deletions(-) diff --git a/backend/go.mod b/backend/go.mod index 1b0d2d174..6d7823895 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -129,4 +129,6 @@ require ( golang.org/x/mod v0.17.0 ) +replace github.com/chenzhuoyu/iasm => github.com/cloudwego/iasm v0.2.0 + //replace github.com/apache/incubator-devlake => ./ diff --git a/backend/go.sum b/backend/go.sum index 761f0153d..ab4c722b7 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -80,6 +80,7 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= diff --git a/backend/plugins/webhook/api/blueprint_v200.go b/backend/plugins/webhook/api/blueprint_v200.go index eaa6be35d..6dca8ff2a 100644 --- a/backend/plugins/webhook/api/blueprint_v200.go +++ b/backend/plugins/webhook/api/blueprint_v200.go @@ -23,6 +23,7 @@ import ( "github.com/apache/incubator-devlake/core/errors" coreModels "github.com/apache/incubator-devlake/core/models" "github.com/apache/incubator-devlake/core/models/domainlayer" + "github.com/apache/incubator-devlake/core/models/domainlayer/code" "github.com/apache/incubator-devlake/core/models/domainlayer/devops" "github.com/apache/incubator-devlake/core/models/domainlayer/ticket" "github.com/apache/incubator-devlake/core/plugin" @@ -54,5 +55,13 @@ func MakeDataSourcePipelinePlanV200(connectionId uint64) (coreModels.PipelinePla Name: connection.Name, }) + // add repos to scopes + scopes = append(scopes, &code.Repo{ + DomainEntity: domainlayer.DomainEntity{ + Id: fmt.Sprintf("%s:%d", "webhook", connection.ID), + }, + Name: connection.Name, + }) + return nil, scopes, nil } diff --git a/backend/plugins/webhook/api/connection.go b/backend/plugins/webhook/api/connection.go index 89ca8843f..16f027d30 100644 --- a/backend/plugins/webhook/api/connection.go +++ b/backend/plugins/webhook/api/connection.go @@ -184,6 +184,7 @@ type WebhookConnectionResponse struct { models.WebhookConnection PostIssuesEndpoint string `json:"postIssuesEndpoint"` CloseIssuesEndpoint string `json:"closeIssuesEndpoint"` + PostPullRequestsEndpoint string `json:"postPullRequestsEndpoint"` PostPipelineTaskEndpoint string `json:"postPipelineTaskEndpoint"` PostPipelineDeployTaskEndpoint string `json:"postPipelineDeployTaskEndpoint"` ClosePipelineEndpoint string `json:"closePipelineEndpoint"` @@ -256,6 +257,7 @@ func formatConnection(connection *models.WebhookConnection, withApiKeyInfo bool) response := &WebhookConnectionResponse{WebhookConnection: *connection} response.PostIssuesEndpoint = fmt.Sprintf(`/rest/plugins/webhook/connections/%d/issues`, connection.ID) response.CloseIssuesEndpoint = fmt.Sprintf(`/rest/plugins/webhook/connections/%d/issue/:issueKey/close`, connection.ID) + response.PostPullRequestsEndpoint = fmt.Sprintf(`/rest/plugins/webhook/connections/%d/pull_requests`, connection.ID) response.PostPipelineTaskEndpoint = fmt.Sprintf(`/rest/plugins/webhook/connections/%d/cicd_tasks`, connection.ID) response.PostPipelineDeployTaskEndpoint = fmt.Sprintf(`/rest/plugins/webhook/connections/%d/deployments`, connection.ID) response.ClosePipelineEndpoint = fmt.Sprintf(`/rest/plugins/webhook/connections/%d/cicd_pipeline/:pipelineName/finish`, connection.ID) diff --git a/backend/plugins/webhook/api/pull_requests.go b/backend/plugins/webhook/api/pull_requests.go new file mode 100644 index 000000000..a01bb6c4d --- /dev/null +++ b/backend/plugins/webhook/api/pull_requests.go @@ -0,0 +1,176 @@ +/* +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" + "net/http" + "time" + + "github.com/apache/incubator-devlake/core/dal" + "github.com/apache/incubator-devlake/core/log" + + "github.com/apache/incubator-devlake/helpers/dbhelper" + "github.com/go-playground/validator/v10" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/models/domainlayer" + "github.com/apache/incubator-devlake/core/models/domainlayer/code" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/webhook/models" +) + +type WebhookPullRequestReq struct { + Id string `mapstructure:"id" validate:"required"` + BaseRepoId string `mapstructure:"baseRepoId"` + HeadRepoId string `mapstructure:"headRepoId"` + Status string `mapstructure:"status" validate:"omitempty,oneof=OPEN CLOSED MERGED"` + OriginalStatus string `mapstructure:"originalStatus"` + Title string `mapstructure:"displayTitle" validate:"required"` + Description string `mapstructure:"description"` + Url string `mapstructure:"url"` + AuthorName string `mapstructure:"authorName"` + AuthorId string `mapstructure:"authorId"` + MergedByName string `mapstructure:"mergedByName"` + MergedById string `mapstructure:"mergedById"` + ParentPrId string `mapstructure:"parentPrId"` + PullRequestKey int `mapstructure:"pullRequestKey" validate:"required"` + CreatedDate time.Time `mapstructure:"createdDate" validate:"required"` + MergedDate *time.Time `mapstructure:"mergedDate"` + ClosedDate *time.Time `mapstructure:"closedDate"` + Type string `mapstructure:"type"` + Component string `mapstructure:"component"` + MergeCommitSha string `mapstructure:"mergeCommitSha"` + HeadRef string `mapstructure:"headRef"` + BaseRef string `mapstructure:"baseRef"` + BaseCommitSha string `mapstructure:"baseCommitSha"` + HeadCommitSha string `mapstructure:"headCommitSha"` + Additions int `mapstructure:"additions"` + Deletions int `mapstructure:"deletions"` + IsDraft bool `mapstructure:"isDraft"` +} + +// PostPullRequests +// @Summary create pull requests by webhook +// @Description Create pull request by webhook.<br/> +// @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","closedDat [...] +// @Description "baseRepoId" must be equal to "webhook:{connectionId}" for this to work correctly and calculate DORA metrics +// @Tags plugins/webhook +// @Param body body WebhookPullRequestReq true "json body" +// @Success 200 +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 403 {string} errcode.Error "Forbidden" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/webhook/connections/:connectionId/pullrequests [POST] +func PostPullRequests(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.WebhookConnection{} + err := connectionHelper.First(connection, input.Params) + + return postPullRequests(input, connection, err) +} + +// PostPullRequestsByName +// @Summary create pull requests by webhook name +// @Description Create pull request by webhook name.<br/> +// @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","closedDat [...] +// @Description "baseRepoId" must be equal to "webhook:{connectionId}" for this to work correctly and calculate DORA metrics +// @Tags plugins/webhook +// @Param body body WebhookPullRequestReq true "json body" +// @Success 200 +// @Failure 400 {string} errcode.Error "Bad Request" +// @Failure 403 {string} errcode.Error "Forbidden" +// @Failure 500 {string} errcode.Error "Internal Error" +// @Router /plugins/webhook/connections/by-name/:connectionName/pullrequests [POST] +func PostPullRequestsByName(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.WebhookConnection{} + err := connectionHelper.FirstByName(connection, input.Params) + + return postPullRequests(input, connection, err) +} + +func postPullRequests(input *plugin.ApiResourceInput, connection *models.WebhookConnection, err errors.Error) (*plugin.ApiResourceOutput, errors.Error) { + if err != nil { + return nil, err + } + // get request + request := &WebhookPullRequestReq{} + err = api.DecodeMapStruct(input.Body, request, true) + if err != nil { + return &plugin.ApiResourceOutput{Body: err.Error(), Status: http.StatusBadRequest}, nil + } + // validate + vld = validator.New() + err = errors.Convert(vld.Struct(request)) + if err != nil { + return nil, errors.BadInput.Wrap(vld.Struct(request), `input json error`) + } + txHelper := dbhelper.NewTxHelper(basicRes, &err) + defer txHelper.End() + tx := txHelper.Begin() + if err := CreatePullRequest(connection, request, tx, logger); err != nil { + logger.Error(err, "create pull requests") + return nil, err + } + + return &plugin.ApiResourceOutput{Body: nil, Status: http.StatusOK}, nil +} + +func CreatePullRequest(connection *models.WebhookConnection, request *WebhookPullRequestReq, tx dal.Transaction, logger log.Logger) errors.Error { + // validation + if request == nil { + return errors.BadInput.New("request body is nil") + } + // create a pull_request record + pullRequest := &code.PullRequest{ + DomainEntity: domainlayer.DomainEntity{ + Id: fmt.Sprintf("%s:%d:%d", "webhook", connection.ID, request.PullRequestKey), + }, + BaseRepoId: fmt.Sprintf("%s:%d", "webhook", connection.ID), + HeadRepoId: request.HeadRepoId, + Status: request.Status, + OriginalStatus: request.OriginalStatus, + Title: request.Title, + Description: request.Description, + Url: request.Url, + AuthorName: request.AuthorName, + AuthorId: request.AuthorId, + MergedByName: request.MergedByName, + MergedById: request.MergedById, + ParentPrId: request.ParentPrId, + PullRequestKey: request.PullRequestKey, + CreatedDate: request.CreatedDate, + MergedDate: request.MergedDate, + ClosedDate: request.ClosedDate, + Type: request.Type, + Component: request.Component, + MergeCommitSha: request.MergeCommitSha, + HeadRef: request.HeadRef, + BaseRef: request.BaseRef, + BaseCommitSha: request.BaseCommitSha, + HeadCommitSha: request.HeadCommitSha, + Additions: request.Additions, + Deletions: request.Deletions, + IsDraft: request.IsDraft, + } + if err := tx.CreateOrUpdate(pullRequest); err != nil { + logger.Error(err, "failed to save pull request") + return err + } + return nil +} diff --git a/backend/plugins/webhook/impl/impl.go b/backend/plugins/webhook/impl/impl.go index 78b1addf5..880cd726a 100644 --- a/backend/plugins/webhook/impl/impl.go +++ b/backend/plugins/webhook/impl/impl.go @@ -90,6 +90,9 @@ func (p Webhook) ApiResources() map[string]map[string]plugin.ApiResourceHandler "connections/:connectionId/deployments": { "POST": api.PostDeployments, }, + "connections/:connectionId/pull_requests": { + "POST": api.PostPullRequests, + }, "connections/:connectionId/issues": { "POST": api.PostIssue, }, @@ -99,6 +102,9 @@ func (p Webhook) ApiResources() map[string]map[string]plugin.ApiResourceHandler ":connectionId/deployments": { "POST": api.PostDeployments, }, + ":connectionId/pull_requests": { + "POST": api.PostPullRequests, + }, ":connectionId/issues": { "POST": api.PostIssue, }, @@ -113,6 +119,9 @@ func (p Webhook) ApiResources() map[string]map[string]plugin.ApiResourceHandler "connections/by-name/:connectionName/deployments": { "POST": api.PostDeploymentsByName, }, + "connections/by-name/:connectionName/pull_requests": { + "POST": api.PostPullRequestsByName, + }, "connections/by-name/:connectionName/issues": { "POST": api.PostIssueByName, }, diff --git a/config-ui/src/features/connections/utils.ts b/config-ui/src/features/connections/utils.ts index 781fb4f11..e64e32d0b 100644 --- a/config-ui/src/features/connections/utils.ts +++ b/config-ui/src/features/connections/utils.ts @@ -50,6 +50,7 @@ export const transformWebhook = (connection: IWebhookAPI): IWebhook => { postIssuesEndpoint: connection.postIssuesEndpoint, closeIssuesEndpoint: connection.closeIssuesEndpoint, postPipelineDeployTaskEndpoint: connection.postPipelineDeployTaskEndpoint, + postPullRequestsEndpoint: connection.postPullRequestsEndpoint, apiKeyId: connection.apiKey.id, }; }; diff --git a/config-ui/src/plugins/register/webhook/components/create-dialog.tsx b/config-ui/src/plugins/register/webhook/components/create-dialog.tsx index 0e2579346..bc08a0750 100644 --- a/config-ui/src/plugins/register/webhook/components/create-dialog.tsx +++ b/config-ui/src/plugins/register/webhook/components/create-dialog.tsx @@ -44,6 +44,7 @@ export const CreateDialog = ({ open, onCancel, onSubmitAfter }: Props) => { postIssuesEndpoint: '', closeIssuesEndpoint: '', postDeploymentsCurl: '', + postPullRequestsEndpoint: '', apiKey: '', }); @@ -55,7 +56,7 @@ export const CreateDialog = ({ open, onCancel, onSubmitAfter }: Props) => { const [success, res] = await operator( async () => { const { - webhook: { id, postIssuesEndpoint, closeIssuesEndpoint, postPipelineDeployTaskEndpoint }, + webhook: { id, postIssuesEndpoint, closeIssuesEndpoint, postPipelineDeployTaskEndpoint, postPullRequestsEndpoint }, apiKey, } = await dispatch(addWebhook({ name })).unwrap(); @@ -65,6 +66,7 @@ export const CreateDialog = ({ open, onCancel, onSubmitAfter }: Props) => { postIssuesEndpoint, closeIssuesEndpoint, postPipelineDeployTaskEndpoint, + postPullRequestsEndpoint, }; }, { @@ -151,6 +153,17 @@ export const CreateDialog = ({ open, onCancel, onSubmitAfter }: Props) => { . </p> </Block> + <Block title="Pull Requests"> + <h5>Post to register a pull request</h5> + <CopyText content={record.postPullRequestsEndpoint} /> + <p> + See the{' '} + <ExternalLink link="https://devlake.apache.org/docs/Plugins/webhook#pull_requests"> + full payload schema + </ExternalLink> + . + </p> + </Block> </S.Wrapper> )} </Modal> diff --git a/config-ui/src/plugins/register/webhook/components/utils.ts b/config-ui/src/plugins/register/webhook/components/utils.ts index 73824d148..ba2fe78bb 100644 --- a/config-ui/src/plugins/register/webhook/components/utils.ts +++ b/config-ui/src/plugins/register/webhook/components/utils.ts @@ -20,9 +20,8 @@ import { IWebhook } from '@/types'; export const transformURI = (prefix: string, webhook: IWebhook, apiKey: string) => { return { - postIssuesEndpoint: `curl ${prefix}${webhook.postIssuesEndpoint} -X 'POST' -H 'Authorization: Bearer ${ - apiKey ?? '{API_KEY}' - }' -d '{ + postIssuesEndpoint: `curl ${prefix}${webhook.postIssuesEndpoint} -X 'POST' -H 'Authorization: Bearer ${apiKey ?? '{API_KEY}' + }' -d '{ "issueKey":"DLK-1234", "title":"an incident from DLK", "type":"INCIDENT", @@ -31,12 +30,10 @@ export const transformURI = (prefix: string, webhook: IWebhook, apiKey: string) "createdDate":"2020-01-01T12:00:00+00:00", "updatedDate":"2020-01-01T12:00:00+00:00" }'`, - closeIssuesEndpoint: `curl ${prefix}${webhook.closeIssuesEndpoint} -X 'POST' -H 'Authorization: Bearer ${ - apiKey ?? '{API_KEY}' - }'`, - postDeploymentsCurl: `curl ${prefix}${webhook.postPipelineDeployTaskEndpoint} -X 'POST' -H 'Authorization: Bearer ${ - apiKey ?? '{API_KEY}' - }' -d '{ + closeIssuesEndpoint: `curl ${prefix}${webhook.closeIssuesEndpoint} -X 'POST' -H 'Authorization: Bearer ${apiKey ?? '{API_KEY}' + }'`, + postDeploymentsCurl: `curl ${prefix}${webhook.postPipelineDeployTaskEndpoint} -X 'POST' -H 'Authorization: Bearer ${apiKey ?? '{API_KEY}' + }' -d '{ "id": "Required. This will be the unique ID of the deployment", "startedDate": "2023-01-01T12:00:00+00:00", "finishedDate": "2023-01-01T12:00:00+00:00", @@ -52,5 +49,26 @@ export const transformURI = (prefix: string, webhook: IWebhook, apiKey: string) } ] }'`, + postPullRequestsEndpoint: `curl ${prefix}${webhook.postPullRequestsEndpoint} -X 'POST' -H 'Authorization: Bearer ${apiKey ?? '{API_KEY}' + }' -d '{ + "id": "Required. This will be the unique ID of the pull request", + "baseRepoId": "your-repo-id", + "headRepoId": "your-repo-id", + "status": "MERGED", + "originalStatus": "OPEN", + "displayTitle": "Feature: Add new functionality", + "description": "This PR adds new features", + "url": "https://github.com/org/repo/pull/1", + "pullRequestKey": 1, + "createdDate": "2025-02-20T16:17:36Z", + "mergedDate": "2025-02-20T17:17:36Z", + "closedDate": null, + "mergeCommitSha": "bf0a79c57dff8f5f1f393de315ee5105a535e059", + "headRef": "your-branch-name", + "baseRef": "main", + "baseCommitSha": "e73325c2c9863f42ea25871cbfaeebcb8edcf604", + "headCommitSha": "b22f772f1197edfafd4cc5fe679a2d299ec12837", + "isDraft": false + }`, }; }; diff --git a/config-ui/src/plugins/register/webhook/components/view-dialog.tsx b/config-ui/src/plugins/register/webhook/components/view-dialog.tsx index e1d45aede..b2ae421c0 100644 --- a/config-ui/src/plugins/register/webhook/components/view-dialog.tsx +++ b/config-ui/src/plugins/register/webhook/components/view-dialog.tsx @@ -94,6 +94,17 @@ export const ViewDialog = ({ initialId, onCancel }: Props) => { . </p> </Block> + <Block title="Pull Requests"> + <h5>Post to register/update a pull_request</h5> + <CopyText content={URI.postPullRequestsEndpoint} /> + <p> + See the{' '} + <ExternalLink link="https://devlake.apache.org/docs/Plugins/webhook#pull_requests"> + full payload schema + </ExternalLink> + . + </p> + </Block> <Block title="API Key" description="If you have forgotten your API key, you can revoke the previous key and generate a new one as a replacement." diff --git a/config-ui/src/types/webhook.ts b/config-ui/src/types/webhook.ts index 2035bf675..c4321739b 100644 --- a/config-ui/src/types/webhook.ts +++ b/config-ui/src/types/webhook.ts @@ -22,6 +22,7 @@ export interface IWebhookAPI { postIssuesEndpoint: string; closeIssuesEndpoint: string; postPipelineDeployTaskEndpoint: string; + postPullRequestsEndpoint: string; apiKey: { id: number; apiKey: string; @@ -34,5 +35,6 @@ export interface IWebhook { postIssuesEndpoint: string; closeIssuesEndpoint: string; postPipelineDeployTaskEndpoint: string; + postPullRequestsEndpoint: string; apiKeyId: number; }