// 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" "time" "github.com/harness/gitness/app/api/usererror" "github.com/harness/gitness/app/auth" "github.com/harness/gitness/gitrpc" "github.com/harness/gitness/types" "github.com/harness/gitness/types/enum" ) type FileViewAddInput struct { Path string `json:"path"` CommitSHA string `json:"commit_sha"` } func (f *FileViewAddInput) Validate() error { if f.Path == "" { return usererror.BadRequest("path can't be empty") } if !gitrpc.ValidateCommitSHA(f.CommitSHA) { return usererror.BadRequest("commit_sha is invalid") } return nil } // FileViewAdd marks a file as viewed. // NOTE: // We take the commit SHA from the user to ensure we mark as viewed only what the user actually sees. // The downside is that the caller could provide a SHA that never was part of the PR in the first place. // We can't block against that with our current data, as the existence of force push makes it impossible to verify // whether the commit ever was part of the PR - it would require us to store the full pr.SourceSHA history. // //nolint:gocognit // refactor if needed. func (c *Controller) FileViewAdd( ctx context.Context, session *auth.Session, repoRef string, prNum int64, in *FileViewAddInput, ) (*types.PullReqFileView, error) { if err := in.Validate(); err != nil { return nil, err } repo, err := c.getRepoCheckAccess(ctx, session, repoRef, enum.PermissionRepoView) if err != nil { return nil, fmt.Errorf("failed to acquire access to repo: %w", err) } pr, err := c.pullreqStore.FindByNumber(ctx, repo.ID, prNum) if err != nil { return nil, fmt.Errorf("failed to find pull request by number: %w", err) } // retrieve file from both provided SHA and mergeBaseSHA to validate user input // TODO: Add GITRPC call to get multiple tree nodes at once inNode, err := c.gitRPCClient.GetTreeNode(ctx, &gitrpc.GetTreeNodeParams{ ReadParams: gitrpc.CreateRPCReadParams(repo), GitREF: in.CommitSHA, Path: in.Path, IncludeLatestCommit: false, }) if err != nil && gitrpc.ErrorStatus(err) != gitrpc.StatusPathNotFound { return nil, fmt.Errorf( "failed to get tree node '%s' for provided sha '%s' from gitrpc: %w", in.Path, in.CommitSHA, err, ) } // ensure provided path actually points to a blob or commit (submodule) if inNode != nil && inNode.Node.Type != gitrpc.TreeNodeTypeBlob && inNode.Node.Type != gitrpc.TreeNodeTypeCommit { return nil, usererror.BadRequestf("Provided path '%s' doesn't point to a file.", in.Path) } mergeBaseNode, err := c.gitRPCClient.GetTreeNode(ctx, &gitrpc.GetTreeNodeParams{ ReadParams: gitrpc.CreateRPCReadParams(repo), GitREF: pr.MergeBaseSHA, Path: in.Path, IncludeLatestCommit: false, }) if err != nil && gitrpc.ErrorStatus(err) != gitrpc.StatusPathNotFound { return nil, fmt.Errorf( "failed to get tree node '%s' for MergeBaseSHA '%s' from gitrpc: %w", in.Path, pr.MergeBaseSHA, err, ) } // fail the call in case the file doesn't exist in either, or in case it didn't change. // NOTE: There is a RARE chance if the user provides an old SHA AND there's a new mergeBaseSHA // which now already contains the changes, that we return an error saying there are no changes // (even though with the old merge base there were). Effectively, it would lead to the users // 'file viewed' resetting when the user refreshes the page - we are okay with that. if inNode == nil && mergeBaseNode == nil { return nil, usererror.BadRequestf( "File '%s' neither found for merge-base '%s' nor for provided sha '%s'.", in.Path, pr.MergeBaseSHA, in.CommitSHA, ) } if inNode != nil && mergeBaseNode != nil && inNode.Node.SHA == mergeBaseNode.Node.SHA { return nil, usererror.BadRequestf( "File '%s' is not part of changes between merge-base '%s' and provided sha '%s'.", in.Path, pr.MergeBaseSHA, in.CommitSHA, ) } // in case of deleted file set sha to nilsha - that's how git diff treats it, too. sha := types.NilSHA if inNode != nil { sha = inNode.Node.SHA } now := time.Now().UnixMilli() fileView := &types.PullReqFileView{ PullReqID: pr.ID, PrincipalID: session.Principal.ID, Path: in.Path, SHA: sha, // always add as non-obsolete, even if the file view is derived from a non-latest commit sha. // The file sha ensures that the user's review is out of date in case the file changed in the meanwhile. // And in the rare case of the file having changed and changed back between the two commits, // the content the reviewer just approved on is the same, so it's actually good to not mark it as obsolete. Obsolete: false, Created: now, Updated: now, } err = c.fileViewStore.Upsert(ctx, fileView) if err != nil { return nil, fmt.Errorf("failed to upsert file view information in db: %w", err) } return fileView, nil }