drone/app/api/controller/githook/post_receive.go

379 lines
11 KiB
Go

// 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 githook
import (
"context"
"fmt"
"slices"
"strings"
"time"
"github.com/harness/gitness/app/api/usererror"
"github.com/harness/gitness/app/auth"
"github.com/harness/gitness/app/bootstrap"
events "github.com/harness/gitness/app/events/git"
repoevents "github.com/harness/gitness/app/events/repo"
"github.com/harness/gitness/git/hook"
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/enum"
"github.com/gotidy/ptr"
"github.com/rs/zerolog/log"
)
const (
// gitReferenceNamePrefixBranch is the prefix of references of type branch.
gitReferenceNamePrefixBranch = "refs/heads/"
// gitReferenceNamePrefixTag is the prefix of references of type tag.
gitReferenceNamePrefixTag = "refs/tags/"
// gitReferenceNamePrefixTag is the prefix of pull req references.
gitReferenceNamePullReq = "refs/pullreq/"
)
// PostReceive executes the post-receive hook for a git repository.
func (c *Controller) PostReceive(
ctx context.Context,
rgit RestrictedGIT,
session *auth.Session,
in types.GithookPostReceiveInput,
) (hook.Output, error) {
repoCore, err := c.getRepoCheckAccess(ctx, session, in.RepoID, enum.PermissionRepoPush)
if err != nil {
return hook.Output{}, err
}
repo, err := c.repoStore.Find(ctx, repoCore.ID)
if err != nil {
return hook.Output{}, err
}
// create output object and have following messages fill its messages
out := hook.Output{}
// update default branch based on ref update info on empty repos.
// as the branch could be different than the configured default value.
c.handleEmptyRepoPush(ctx, repo, in.PostReceiveInput, &out)
// always update last git push time - best effort
c.updateLastGITPushTime(ctx, repo, in)
// report ref events if repo is in an active state - best effort
if repo.State == enum.RepoStateActive {
c.reportReferenceEvents(ctx, rgit, repo, in.PrincipalID, in.PostReceiveInput)
}
// handle branch updates related to PRs - best effort
c.handlePRMessaging(ctx, repo, in.PostReceiveInput, &out)
err = c.postReceiveExtender.Extend(ctx, rgit, session, repo.Core(), in, &out)
if err != nil {
return hook.Output{}, fmt.Errorf("failed to extend post-receive hook: %w", err)
}
return out, nil
}
// reportReferenceEvents is reporting reference events to the event system.
// NOTE: keep best effort for now as it doesn't change the outcome of the git operation.
// TODO: in the future we might want to think about propagating errors so user is aware of events not being triggered.
func (c *Controller) reportReferenceEvents(
ctx context.Context,
rgit RestrictedGIT,
repo *types.Repository,
principalID int64,
in hook.PostReceiveInput,
) {
for _, refUpdate := range in.RefUpdates {
switch {
case strings.HasPrefix(refUpdate.Ref, gitReferenceNamePrefixBranch):
c.reportBranchEvent(ctx, rgit, repo, principalID, in.Environment, refUpdate)
case strings.HasPrefix(refUpdate.Ref, gitReferenceNamePrefixTag):
c.reportTagEvent(ctx, repo, principalID, refUpdate)
default:
// Ignore any other references in post-receive
}
}
}
func (c *Controller) reportBranchEvent(
ctx context.Context,
rgit RestrictedGIT,
repo *types.Repository,
principalID int64,
env hook.Environment,
branchUpdate hook.ReferenceUpdate,
) {
switch {
case branchUpdate.Old.IsNil():
payload := &events.BranchCreatedPayload{
RepoID: repo.ID,
PrincipalID: principalID,
Ref: branchUpdate.Ref,
SHA: branchUpdate.New.String(),
}
c.gitReporter.BranchCreated(ctx, payload)
c.sseStreamer.Publish(ctx, repo.ParentID, enum.SSETypeBranchCreated, payload)
case branchUpdate.New.IsNil():
payload := &events.BranchDeletedPayload{
RepoID: repo.ID,
PrincipalID: principalID,
Ref: branchUpdate.Ref,
SHA: branchUpdate.Old.String(),
}
c.gitReporter.BranchDeleted(ctx, payload)
c.sseStreamer.Publish(ctx, repo.ParentID, enum.SSETypeBranchDeleted, payload)
default:
// A force update event might trigger some additional operations that aren't required
// for ordinary updates (force pushes alter the commit history of a branch).
forced, err := isForcePush(ctx, rgit, repo.GitUID, env.AlternateObjectDirs, branchUpdate)
if err != nil {
// In case of an error consider this a forced update. In post-update the branch has already been updated,
// so there's less harm in declaring the update as forced.
forced = true
log.Ctx(ctx).Warn().Err(err).
Str("ref", branchUpdate.Ref).
Msg("failed to check ancestor")
}
payload := &events.BranchUpdatedPayload{
RepoID: repo.ID,
PrincipalID: principalID,
Ref: branchUpdate.Ref,
OldSHA: branchUpdate.Old.String(),
NewSHA: branchUpdate.New.String(),
Forced: forced,
}
c.gitReporter.BranchUpdated(ctx, payload)
c.sseStreamer.Publish(ctx, repo.ParentID, enum.SSETypeBranchUpdated, payload)
}
}
func (c *Controller) reportTagEvent(
ctx context.Context,
repo *types.Repository,
principalID int64,
tagUpdate hook.ReferenceUpdate,
) {
switch {
case tagUpdate.Old.IsNil():
payload := &events.TagCreatedPayload{
RepoID: repo.ID,
PrincipalID: principalID,
Ref: tagUpdate.Ref,
SHA: tagUpdate.New.String(),
}
c.gitReporter.TagCreated(ctx, payload)
c.sseStreamer.Publish(ctx, repo.ParentID, enum.SSETypeTagCreated, payload)
case tagUpdate.New.IsNil():
payload := &events.TagDeletedPayload{
RepoID: repo.ID,
PrincipalID: principalID,
Ref: tagUpdate.Ref,
SHA: tagUpdate.Old.String(),
}
c.gitReporter.TagDeleted(ctx, payload)
c.sseStreamer.Publish(ctx, repo.ParentID, enum.SSETypeTagDeleted, payload)
default:
payload := &events.TagUpdatedPayload{
RepoID: repo.ID,
PrincipalID: principalID,
Ref: tagUpdate.Ref,
OldSHA: tagUpdate.Old.String(),
NewSHA: tagUpdate.New.String(),
// tags can only be force updated!
Forced: true,
}
c.gitReporter.TagUpdated(ctx, payload)
c.sseStreamer.Publish(ctx, repo.ParentID, enum.SSETypeTagUpdated, payload)
}
}
// handlePRMessaging checks any single branch push for pr information and returns an according response if needed.
// TODO: If it is a new branch, or an update on a branch without any PR, it also sends out an SSE for pr creation.
func (c *Controller) handlePRMessaging(
ctx context.Context,
repo *types.Repository,
in hook.PostReceiveInput,
out *hook.Output,
) {
// skip anything that was a batch push / isn't branch related / isn't updating/creating a branch.
if len(in.RefUpdates) != 1 ||
!strings.HasPrefix(in.RefUpdates[0].Ref, gitReferenceNamePrefixBranch) ||
in.RefUpdates[0].New.IsNil() {
return
}
// for now we only care about first branch that was pushed.
branchName := in.RefUpdates[0].Ref[len(gitReferenceNamePrefixBranch):]
c.suggestPullRequest(ctx, repo, branchName, out)
// TODO: store latest pushed branch for user in cache and send out SSE
}
func (c *Controller) suggestPullRequest(
ctx context.Context,
repo *types.Repository,
branchName string,
out *hook.Output,
) {
if branchName == repo.DefaultBranch {
// Don't suggest a pull request if this is a push to the default branch.
return
}
// do we have a PR related to it?
prs, err := c.pullreqStore.List(ctx, &types.PullReqFilter{
Page: 1,
// without forks we expect at most one PR (keep 2 to not break when forks are introduced)
Size: 2,
SourceRepoID: repo.ID,
SourceBranch: branchName,
// we only care about open PRs - merged/closed will lead to "create new PR" message
States: []enum.PullReqState{enum.PullReqStateOpen},
Order: enum.OrderAsc,
Sort: enum.PullReqSortCreated,
// don't care about the PR description, omit it from the response
ExcludeDescription: true,
})
if err != nil {
log.Ctx(ctx).Warn().Err(err).Msgf(
"failed to find pullrequests for branch '%s' originating from repo '%s'",
branchName,
repo.Path,
)
return
}
// for already existing PRs, print them to users terminal for easier access.
if len(prs) > 0 {
msgs := make([]string, 2*len(prs)+1)
msgs[0] = fmt.Sprintf("Branch %q has open PRs:", branchName)
for i, pr := range prs {
msgs[2*i+1] = fmt.Sprintf(" (#%d) %s", pr.Number, pr.Title)
msgs[2*i+2] = " " + c.urlProvider.GenerateUIPRURL(ctx, repo.Path, pr.Number)
}
out.Messages = append(out.Messages, msgs...)
return
}
// this is a new PR!
out.Messages = append(out.Messages,
fmt.Sprintf("Create a pull request for %q by visiting:", branchName),
" "+c.urlProvider.GenerateUICompareURL(ctx, repo.Path, repo.DefaultBranch, branchName),
)
}
// handleEmptyRepoPush updates repo default branch on empty repos if push contains branches.
func (c *Controller) handleEmptyRepoPush(
ctx context.Context,
repo *types.Repository,
in hook.PostReceiveInput,
out *hook.Output,
) {
if !repo.IsEmpty {
return
}
var newDefaultBranch string
// update default branch if corresponding branch does not exist
for _, refUpdate := range in.RefUpdates {
if strings.HasPrefix(refUpdate.Ref, gitReferenceNamePrefixBranch) && !refUpdate.New.IsNil() {
branchName := refUpdate.Ref[len(gitReferenceNamePrefixBranch):]
if branchName == repo.DefaultBranch {
newDefaultBranch = branchName
break
}
// use the first pushed branch if default branch is not present.
if newDefaultBranch == "" {
newDefaultBranch = branchName
}
}
}
if newDefaultBranch == "" {
out.Error = ptr.String(usererror.ErrEmptyRepoNeedsBranch.Error())
return
}
oldName := repo.DefaultBranch
var err error
repo, err = c.repoStore.UpdateOptLock(ctx, repo, func(r *types.Repository) error {
r.IsEmpty = false
r.DefaultBranch = newDefaultBranch
return nil
})
if err != nil {
log.Ctx(ctx).Warn().Err(err).Msgf("failed to update the repo default branch to %s and is_empty to false",
newDefaultBranch)
return
}
c.repoFinder.MarkChanged(ctx, repo.Core())
if repo.DefaultBranch != oldName {
c.repoReporter.DefaultBranchUpdated(ctx, &repoevents.DefaultBranchUpdatedPayload{
RepoID: repo.ID,
PrincipalID: bootstrap.NewSystemServiceSession().Principal.ID,
OldName: oldName,
NewName: repo.DefaultBranch,
})
}
}
// updateLastGITPushTime updates the repo's last git push time.
func (c *Controller) updateLastGITPushTime(
ctx context.Context,
repo *types.Repository,
in types.GithookPostReceiveInput,
) {
isNonePRRefFn := func(refUpdate hook.ReferenceUpdate) bool {
return !strings.HasPrefix(refUpdate.Ref, gitReferenceNamePullReq)
}
// ignore push that only contains pr refs for last git push time updates
if !slices.ContainsFunc(in.RefUpdates, isNonePRRefFn) {
return
}
newRepo, err := c.repoStore.UpdateOptLock(ctx, repo, func(r *types.Repository) error {
r.LastGITPush = time.Now().UnixMilli()
return nil
})
if err != nil {
log.Ctx(ctx).Warn().Err(err).Msgf("failed to update last git push time for repo %q", repo.Path)
return
}
*repo = *newRepo
}