diff --git a/app/api/controller/pullreq/branch_change_target.go b/app/api/controller/pullreq/branch_change_target.go new file mode 100644 index 000000000..a83fbd80d --- /dev/null +++ b/app/api/controller/pullreq/branch_change_target.go @@ -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 +} diff --git a/app/api/controller/pullreq/delete_branch.go b/app/api/controller/pullreq/branch_delete.go similarity index 100% rename from app/api/controller/pullreq/delete_branch.go rename to app/api/controller/pullreq/branch_delete.go diff --git a/app/api/controller/pullreq/restore_branch.go b/app/api/controller/pullreq/branch_restore.go similarity index 100% rename from app/api/controller/pullreq/restore_branch.go rename to app/api/controller/pullreq/branch_restore.go diff --git a/app/api/handler/pullreq/branch_change_target.go b/app/api/handler/pullreq/branch_change_target.go new file mode 100644 index 000000000..cbf339e85 --- /dev/null +++ b/app/api/handler/pullreq/branch_change_target.go @@ -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) + } +} diff --git a/app/api/handler/pullreq/delete_branch.go b/app/api/handler/pullreq/branch_delete.go similarity index 100% rename from app/api/handler/pullreq/delete_branch.go rename to app/api/handler/pullreq/branch_delete.go diff --git a/app/api/handler/pullreq/restore_branch.go b/app/api/handler/pullreq/branch_restore.go similarity index 100% rename from app/api/handler/pullreq/restore_branch.go rename to app/api/handler/pullreq/branch_restore.go diff --git a/app/api/openapi/pullreq.go b/app/api/openapi/pullreq.go index e8ad954be..6bbc7c83a 100644 --- a/app/api/openapi/pullreq.go +++ b/app/api/openapi/pullreq.go @@ -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"}) diff --git a/app/events/pullreq/events_branch.go b/app/events/pullreq/events_branch.go index 01a1aa090..cebf9d7b6 100644 --- a/app/events/pullreq/events_branch.go +++ b/app/events/pullreq/events_branch.go @@ -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...) +} diff --git a/app/router/api.go b/app/router/api.go index 8bf649d44..c562d4a5e 100644 --- a/app/router/api.go +++ b/app/router/api.go @@ -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) { diff --git a/app/services/instrument/instrument.go b/app/services/instrument/instrument.go index 710562344..a330ab052 100644 --- a/app/services/instrument/instrument.go +++ b/app/services/instrument/instrument.go @@ -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" diff --git a/app/services/pullreq/handlers_mergeable.go b/app/services/pullreq/handlers_mergeable.go index 9077809cb..e01062e05 100644 --- a/app/services/pullreq/handlers_mergeable.go +++ b/app/services/pullreq/handlers_mergeable.go @@ -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, diff --git a/app/services/pullreq/service.go b/app/services/pullreq/service.go index eb4ea0fee..a9fd9dab3 100644 --- a/app/services/pullreq/service.go +++ b/app/services/pullreq/service.go @@ -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 }) diff --git a/app/store/database/pullreq.go b/app/store/database/pullreq.go index cf35f059c..ff3d28e3a 100644 --- a/app/store/database/pullreq.go +++ b/app/store/database/pullreq.go @@ -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 diff --git a/types/enum/pullreq.go b/types/enum/pullreq.go index 33d082bc1..ca57cbcab 100644 --- a/types/enum/pullreq.go +++ b/types/enum/pullreq.go @@ -78,18 +78,19 @@ func GetAllPullReqActivityTypes() ([]PullReqActivityType, PullReqActivityType) { // PullReqActivityType enumeration. const ( - PullReqActivityTypeComment PullReqActivityType = "comment" - PullReqActivityTypeCodeComment PullReqActivityType = "code-comment" - PullReqActivityTypeTitleChange PullReqActivityType = "title-change" - PullReqActivityTypeStateChange PullReqActivityType = "state-change" - PullReqActivityTypeReviewSubmit PullReqActivityType = "review-submit" - PullReqActivityTypeReviewerAdd PullReqActivityType = "reviewer-add" - PullReqActivityTypeReviewerDelete PullReqActivityType = "reviewer-delete" - PullReqActivityTypeBranchUpdate PullReqActivityType = "branch-update" - PullReqActivityTypeBranchDelete PullReqActivityType = "branch-delete" - PullReqActivityTypeBranchRestore PullReqActivityType = "branch-restore" - PullReqActivityTypeMerge PullReqActivityType = "merge" - PullReqActivityTypeLabelModify PullReqActivityType = "label-modify" + PullReqActivityTypeComment PullReqActivityType = "comment" + PullReqActivityTypeCodeComment PullReqActivityType = "code-comment" + PullReqActivityTypeTitleChange PullReqActivityType = "title-change" + PullReqActivityTypeStateChange PullReqActivityType = "state-change" + PullReqActivityTypeReviewSubmit PullReqActivityType = "review-submit" + PullReqActivityTypeReviewerAdd PullReqActivityType = "reviewer-add" + PullReqActivityTypeReviewerDelete PullReqActivityType = "reviewer-delete" + PullReqActivityTypeBranchUpdate PullReqActivityType = "branch-update" + PullReqActivityTypeBranchDelete PullReqActivityType = "branch-delete" + PullReqActivityTypeBranchRestore PullReqActivityType = "branch-restore" + PullReqActivityTypeTargetBranchChange PullReqActivityType = "target-branch-change" + PullReqActivityTypeMerge PullReqActivityType = "merge" + PullReqActivityTypeLabelModify PullReqActivityType = "label-modify" ) var pullReqActivityTypes = sortEnum([]PullReqActivityType{ @@ -103,6 +104,7 @@ var pullReqActivityTypes = sortEnum([]PullReqActivityType{ PullReqActivityTypeBranchUpdate, PullReqActivityTypeBranchDelete, PullReqActivityTypeBranchRestore, + PullReqActivityTypeTargetBranchChange, PullReqActivityTypeMerge, PullReqActivityTypeLabelModify, }) diff --git a/types/pullreq_activity_payload.go b/types/pullreq_activity_payload.go index 8c00f9852..3398d182e 100644 --- a/types/pullreq_activity_payload.go +++ b/types/pullreq_activity_payload.go @@ -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"`