feat: [CODE-1277]: Add support for PR target branch change (#3579)

* Merge remote-tracking branch 'origin/main' into dd/change-target-branch
* Merge remote-tracking branch 'origin/main' into dd/change-target-branch
* Add openapi spec for opChangeTargetBranch
* Fix updateMergeData args
* Refactor ChangeTargetBranch to use verifyBranchExistence and git.GetRefPath
* Merge remote-tracking branch 'origin/main' into dd/change-target-branch
* Refactor instumentation and event payload
* Change instrumentation and event payload
* Add target-branch-changed event
* Get new merge base and clear merge related fields
* Merge remote-tracking branch 'origin/main' into dd/change-target-branch
* Merge remote-tracking branch 'origin/main' into dd/change-target-branch
* Add merge check to target branch change
* Merge remote-tracking branch 'origin/main' into dd/change-target-branch
* Add new PullReqActivityType and EventType for audit track
* Add support for PR target branch change
This commit is contained in:
Darko Draskovic 2025-03-28 16:04:15 +00:00 committed by Harness
parent cc2a63b797
commit 60a182e515
15 changed files with 306 additions and 13 deletions

View File

@ -0,0 +1,148 @@
// Copyright 2023 Harness, Inc.
//
// Licensed 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 pullreq
import (
"context"
"fmt"
"github.com/harness/gitness/app/api/usererror"
"github.com/harness/gitness/app/auth"
pullreqevents "github.com/harness/gitness/app/events/pullreq"
"github.com/harness/gitness/app/services/instrument"
"github.com/harness/gitness/errors"
"github.com/harness/gitness/git"
gitenum "github.com/harness/gitness/git/enum"
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/enum"
"github.com/rs/zerolog/log"
)
type ChangeTargetBranchInput struct {
BranchName string `json:"branch_name"`
}
func (c *Controller) ChangeTargetBranch(ctx context.Context,
session *auth.Session,
repoRef string,
pullreqNum int64,
in *ChangeTargetBranchInput,
) (*types.PullReq, error) {
repo, err := c.getRepoCheckAccess(ctx, session, repoRef, enum.PermissionRepoPush)
if err != nil {
return nil, fmt.Errorf("failed to acquire access to repo: %w", err)
}
pr, err := c.pullreqStore.FindByNumber(ctx, repo.ID, pullreqNum)
if err != nil {
return nil, fmt.Errorf("failed to get pull request by number: %w", err)
}
if _, err = c.verifyBranchExistence(ctx, repo, in.BranchName); err != nil {
return nil,
fmt.Errorf("failed to verify branch existence: %w", err)
}
if pr.TargetBranch == in.BranchName {
return pr, nil
}
if pr.SourceBranch == in.BranchName {
return nil,
errors.InvalidArgument("source branch %q is same as new target branch", pr.SourceBranch)
}
ref1, err := git.GetRefPath(pr.SourceBranch, gitenum.RefTypeBranch)
if err != nil {
return nil, fmt.Errorf("failed to get ref path: %w", err)
}
ref2, err := git.GetRefPath(in.BranchName, gitenum.RefTypeBranch)
if err != nil {
return nil, fmt.Errorf("failed to get ref path: %w", err)
}
mergeBase, err := c.git.MergeBase(ctx, git.MergeBaseParams{
ReadParams: git.ReadParams{RepoUID: repo.GitUID},
Ref1: ref1,
Ref2: ref2,
})
if err != nil {
return nil, fmt.Errorf("failed to find merge base: %w", err)
}
if mergeBase.MergeBaseSHA.String() == pr.SourceSHA {
return nil,
usererror.BadRequest("The source branch doesn't contain any new commits")
}
oldTargetBranch := pr.TargetBranch
oldMergeBaseSHA := pr.MergeBaseSHA
_, err = c.pullreqStore.UpdateOptLock(ctx, pr, func(pr *types.PullReq) error {
// clear merge and stats related fields
pr.MergeSHA = nil
pr.MergeTargetSHA = nil
pr.Stats.DiffStats = types.DiffStats{}
pr.MergeBaseSHA = mergeBase.MergeBaseSHA.String()
pr.TargetBranch = in.BranchName
pr.MarkAsMergeUnchecked()
pr.ActivitySeq++
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to update PR target branch in db with error: %w", err)
}
_, err = c.activityStore.CreateWithPayload(
ctx, pr, session.Principal.ID,
&types.PullRequestActivityPayloadBranchChangeTarget{
Old: oldTargetBranch,
New: in.BranchName,
},
nil,
)
if err != nil {
log.Ctx(ctx).Err(err).Msgf("failed to write pull request activity for successful branch restore")
}
err = c.instrumentation.Track(ctx, instrument.Event{
Type: instrument.EventTypeChangeTargetBranch,
Principal: session.Principal.ToPrincipalInfo(),
Path: repo.Path,
Properties: map[instrument.Property]any{
instrument.PropertyRepositoryID: repo.ID,
instrument.PropertyRepositoryName: repo.Identifier,
instrument.PropertyTargetBranch: in.BranchName,
instrument.PropertyPullRequestID: pr.Number,
},
})
if err != nil {
log.Ctx(ctx).Warn().Msgf("failed to insert instrumentation record for create branch operation: %s", err)
}
c.eventReporter.TargetBranchChanged(ctx, &pullreqevents.TargetBranchChangedPayload{
Base: eventBase(pr, &session.Principal),
SourceSHA: pr.SourceSHA,
OldTargetBranch: oldTargetBranch,
NewTargetBranch: in.BranchName,
OldMergeBaseSHA: oldMergeBaseSHA,
NewMergeBaseSHA: mergeBase.MergeBaseSHA.String(),
})
return pr, nil
}

View File

@ -0,0 +1,60 @@
// Copyright 2023 Harness, Inc.
//
// Licensed 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 pullreq
import (
"encoding/json"
"net/http"
"github.com/harness/gitness/app/api/controller/pullreq"
"github.com/harness/gitness/app/api/render"
"github.com/harness/gitness/app/api/request"
)
func HandleChangeTargetBranch(pullreqCtrl *pullreq.Controller) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
session, _ := request.AuthSessionFrom(ctx)
repoRef, err := request.GetRepoRefFromPath(r)
if err != nil {
render.TranslatedUserError(ctx, w, err)
return
}
pullreqNumber, err := request.GetPullReqNumberFromPath(r)
if err != nil {
render.TranslatedUserError(ctx, w, err)
return
}
in := new(pullreq.ChangeTargetBranchInput)
err = json.NewDecoder(r.Body).Decode(in)
if err != nil {
render.BadRequestf(ctx, w, "Invalid request body: %s.", err)
return
}
out, err := pullreqCtrl.ChangeTargetBranch(
ctx, session, repoRef, pullreqNumber, in,
)
if err != nil {
render.TranslatedUserError(ctx, w, err)
return
}
render.JSON(w, http.StatusOK, out)
}
}

View File

@ -757,6 +757,21 @@ func pullReqOperations(reflector *openapi3.Reflector) {
_ = reflector.SetJSONResponse(&opDeleteBranch, new(types.RulesViolations), http.StatusUnprocessableEntity)
_ = reflector.Spec.AddOperation(http.MethodDelete, "/repos/{repo_ref}/pullreq/{pullreq_number}/branch", opDeleteBranch)
opChangeTargetBranch := openapi3.Operation{}
opChangeTargetBranch.WithTags("pullreq")
opChangeTargetBranch.WithMapOfAnything(map[string]interface{}{"operationId": "changeTargetBranch"})
_ = reflector.SetRequest(&opChangeTargetBranch, struct {
pullReqRequest
pullreq.ChangeTargetBranchInput
}{}, http.MethodPost)
_ = reflector.SetJSONResponse(&opChangeTargetBranch, new(types.PullReq), http.StatusOK)
_ = reflector.SetJSONResponse(&opChangeTargetBranch, new(usererror.Error), http.StatusBadRequest)
_ = reflector.SetJSONResponse(&opChangeTargetBranch, new(usererror.Error), http.StatusInternalServerError)
_ = reflector.SetJSONResponse(&opChangeTargetBranch, new(usererror.Error), http.StatusUnauthorized)
_ = reflector.SetJSONResponse(&opChangeTargetBranch, new(usererror.Error), http.StatusForbidden)
_ = reflector.Spec.AddOperation(http.MethodPut,
"/repos/{repo_ref}/pullreq/{pullreq_number}/branch", opChangeTargetBranch)
fileViewAdd := openapi3.Operation{}
fileViewAdd.WithTags("pullreq")
fileViewAdd.WithMapOfAnything(map[string]interface{}{"operationId": "fileViewAddPullReq"})

View File

@ -47,7 +47,46 @@ func (r *Reporter) BranchUpdated(ctx context.Context, payload *BranchUpdatedPayl
log.Ctx(ctx).Debug().Msgf("reported pull request branch updated event with id '%s'", eventID)
}
func (r *Reader) RegisterBranchUpdated(fn events.HandlerFunc[*BranchUpdatedPayload],
func (r *Reader) RegisterBranchUpdated(
fn events.HandlerFunc[*BranchUpdatedPayload],
opts ...events.HandlerOption) error {
return events.ReaderRegisterEvent(r.innerReader, BranchUpdatedEvent, fn, opts...)
}
const TargetBranchChangedEvent events.EventType = "target-branch-changed"
type TargetBranchChangedPayload struct {
Base
SourceSHA string `json:"source_sha"`
OldTargetBranch string `json:"old_target_branch"`
NewTargetBranch string `json:"new_target_branch"`
OldMergeBaseSHA string `json:"old_merge_base_sha"`
NewMergeBaseSHA string `json:"new_merge_base_sha"`
}
func (r *Reporter) TargetBranchChanged(
ctx context.Context,
payload *TargetBranchChangedPayload,
) {
if payload == nil {
return
}
eventID, err := events.ReporterSendEvent(
r.innerReporter, ctx, TargetBranchChangedEvent, payload,
)
if err != nil {
log.Ctx(ctx).Err(err).Msgf("failed to send pull request target branch changed event")
return
}
log.Ctx(ctx).Debug().Msgf(
"reported pull request target branch changed event with id '%s'", eventID,
)
}
func (r *Reader) RegisterTargetBranchChanged(
fn events.HandlerFunc[*TargetBranchChangedPayload],
opts ...events.HandlerOption) error {
return events.ReaderRegisterEvent(r.innerReader, TargetBranchChangedEvent, fn, opts...)
}

View File

@ -694,6 +694,7 @@ func SetupPullReq(r chi.Router, pullreqCtrl *pullreq.Controller) {
r.Route("/branch", func(r chi.Router) {
r.Post("/", handlerpullreq.HandleRestoreBranch(pullreqCtrl))
r.Delete("/", handlerpullreq.HandleDeleteBranch(pullreqCtrl))
r.Put("/", handlerpullreq.HandleChangeTargetBranch(pullreqCtrl))
})
r.Route("/file-views", func(r chi.Router) {

View File

@ -42,6 +42,7 @@ const (
PropertyIsDefaultBranch Property = "is_default_branch"
PropertyDecision Property = "decision"
PropertyRepositories Property = "repositories"
PropertyTargetBranch Property = "target_branch"
PropertySpaceID Property = "space_id"
PropertySpaceName Property = "space_name"
@ -55,6 +56,7 @@ const (
EventTypeCommitCount EventType = "Commit count"
EventTypeCreateCommit EventType = "Create commit"
EventTypeCreateBranch EventType = "Create branch"
EventTypeChangeTargetBranch EventType = "Change target branch"
EventTypeCreateTag EventType = "Create tag"
EventTypeCreatePullRequest EventType = "Create pull request"
EventTypeMergePullRequest EventType = "Merge pull request"

View File

@ -65,6 +65,20 @@ func (s *Service) mergeCheckOnBranchUpdate(ctx context.Context,
)
}
// mergeCheckOnTargetBranchChange handles pull request target branch changed events.
func (s *Service) mergeCheckOnTargetBranchChange(
ctx context.Context,
event *events.Event[*pullreqevents.TargetBranchChangedPayload],
) error {
return s.updateMergeData(
ctx,
event.Payload.TargetRepoID,
event.Payload.Number,
sha.None.String(),
event.Payload.SourceSHA,
)
}
// mergeCheckOnReopen handles pull request StateChanged events.
// It updates the PR head git ref to point to the source branch commit SHA.
func (s *Service) mergeCheckOnReopen(ctx context.Context,

View File

@ -195,6 +195,7 @@ func New(ctx context.Context,
_ = r.RegisterCreated(service.mergeCheckOnCreated)
_ = r.RegisterBranchUpdated(service.mergeCheckOnBranchUpdate)
_ = r.RegisterReopened(service.mergeCheckOnReopen)
_ = r.RegisterTargetBranchChanged(service.mergeCheckOnTargetBranchChange)
return nil
})

View File

@ -302,6 +302,7 @@ func (s *PullReqStore) Update(ctx context.Context, pr *types.PullReq) error {
,pullreq_description = :pullreq_description
,pullreq_activity_seq = :pullreq_activity_seq
,pullreq_source_sha = :pullreq_source_sha
,pullreq_target_branch = :pullreq_target_branch
,pullreq_merged_by = :pullreq_merged_by
,pullreq_merged = :pullreq_merged
,pullreq_merge_method = :pullreq_merge_method

View File

@ -88,6 +88,7 @@ const (
PullReqActivityTypeBranchUpdate PullReqActivityType = "branch-update"
PullReqActivityTypeBranchDelete PullReqActivityType = "branch-delete"
PullReqActivityTypeBranchRestore PullReqActivityType = "branch-restore"
PullReqActivityTypeTargetBranchChange PullReqActivityType = "target-branch-change"
PullReqActivityTypeMerge PullReqActivityType = "merge"
PullReqActivityTypeLabelModify PullReqActivityType = "label-modify"
)
@ -103,6 +104,7 @@ var pullReqActivityTypes = sortEnum([]PullReqActivityType{
PullReqActivityTypeBranchUpdate,
PullReqActivityTypeBranchDelete,
PullReqActivityTypeBranchRestore,
PullReqActivityTypeTargetBranchChange,
PullReqActivityTypeMerge,
PullReqActivityTypeLabelModify,
})

View File

@ -179,6 +179,15 @@ func (a *PullRequestActivityPayloadBranchRestore) ActivityType() enum.PullReqAct
return enum.PullReqActivityTypeBranchRestore
}
type PullRequestActivityPayloadBranchChangeTarget struct {
Old string `json:"old"`
New string `json:"new"`
}
func (a *PullRequestActivityPayloadBranchChangeTarget) ActivityType() enum.PullReqActivityType {
return enum.PullReqActivityTypeTargetBranchChange
}
type PullRequestActivityLabelBase struct {
Label string `json:"label"`
LabelColor enum.LabelColor `json:"label_color"`
@ -188,6 +197,7 @@ type PullRequestActivityLabelBase struct {
OldValue *string `json:"old_value,omitempty"`
OldValueColor *enum.LabelColor `json:"old_value_color,omitempty"`
}
type PullRequestActivityLabel struct {
PullRequestActivityLabelBase
Type enum.PullReqLabelActivityType `json:"type"`