diff --git a/.golangci.yml b/.golangci.yml index 615943c5..031a0b99 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -5,8 +5,8 @@ linters-settings: check-type-assertions: true forbidigo: forbid: - - '^print$' - - '^println$' + - "^print$" + - "^println$" linters: enable: - asciicheck diff --git a/pkg/repo/repo.go b/pkg/repo/repo.go index 7b35723b..5b862cdb 100644 --- a/pkg/repo/repo.go +++ b/pkg/repo/repo.go @@ -17,8 +17,10 @@ package repo import ( "context" "fmt" + "io" "net/url" "os" + "path/filepath" "strings" "time" @@ -42,6 +44,8 @@ import ( const ( RemoteName = "origin" backoffMaxElapsed = 7 * time.Second + // Username for LekkoApp bot. + LekkoAppUser = "lekko-app[bot]" ) var ( @@ -49,6 +53,20 @@ var ( ErrNotFound = fmt.Errorf("not found") ) +type Author struct { + Name string + Email string +} + +type HistoryItem struct { + Description string + Author Author + CoAuthors []Author + ConfigContents map[string][]string // Map of namespaces to changed configs (added or updated) + CommitSHA string + Timestamp time.Time +} + // ConfigurationRepository provides read and write access to Lekko configuration // stored in a git repository. type ConfigurationRepository interface { @@ -63,6 +81,10 @@ type ConfigurationRepository interface { // Underlying filesystem interfaces fs.Provider fs.ConfigWriter + + // Returns the history of changes made on the repository. Items can also be filtered to config. + // Offset (0-based) and maxLen are required to limit the number of history items returned. + GetHistory(ctx context.Context, namespace, configName string, offset, maxLen int32) ([]*HistoryItem, error) } // Provides functionality for interacting with git. @@ -476,6 +498,34 @@ func GetCommitSignature(ctx context.Context, ap AuthProvider, lekkoUser string) }, nil } +// Returns the coauthor name and email based on the long git commit message. +// e.g. `Co-authored-by: `. +// TODO: Consider if we want to properly handle multiple coauthors. +func GetCoauthorInformation(commitMessage string) (string, string) { + var coauthorName, coauthorEmail string + for _, line := range strings.Split(commitMessage, "\n") { + if strings.HasPrefix(line, "Co-authored-by:") { + rest := strings.TrimPrefix(line, "Co-authored-by:") + if strings.HasSuffix(rest, ">") { + parts := strings.Split(rest, " ") + coauthorName = strings.TrimSpace(strings.Join(parts[:len(parts)-1], " ")) + email := parts[len(parts)-1] + coauthorEmail = strings.TrimPrefix(strings.TrimSuffix(email, ">"), "<") + } else { // no email present, i.e. 'Co-authored-by: coauthor_name' + coauthorName = strings.TrimSpace(rest) + } + if coauthorName == LekkoAppUser { + // This is not the coauthor we want + coauthorName = "" + coauthorEmail = "" + continue + } + break + } + } + return coauthorName, coauthorEmail +} + // Cleans up all resources and references associated with the given branch on // local and remote, if they exist. If branchName is nil, uses the current // (non-master) branch. Will switch the current branch back to the default, and @@ -722,6 +772,100 @@ func (r *repository) NewRemoteBranch(branchName string) error { return nil } +// NOTE: Currently untested for very large numbers of files changed. +// TODO: Extract changed configs detection logic as a util if usable in other contexts +func (r *repository) GetHistory(ctx context.Context, namespace, configName string, offset, maxLen int32) ([]*HistoryItem, error) { + if err := r.wt.Checkout(&git.CheckoutOptions{ + Branch: plumbing.NewBranchReferenceName(r.DefaultBranchName()), + }); err != nil { + return nil, errors.Wrap(err, "checkout default branch") + } + + commitIter, err := r.repo.Log(&git.LogOptions{ + Order: git.LogOrderCommitterTime, + // NOTE: It's possible to add a path filter here but since we're extracting + // change info below, we manually filter there - saves on duplicated logic + // and seems to be more efficient + }) + if err != nil { + return nil, errors.Wrap(err, "get commit log of default branch") + } + defer commitIter.Close() + + var pathFilterFn func(string) bool + if len(namespace) > 0 && len(configName) > 0 { + pathFilterFn = func(path string) bool { + return strings.HasSuffix(path, fmt.Sprintf("%s/%s.star", namespace, configName)) + } + } + + var history []*HistoryItem + for i := int32(0); i < offset+maxLen; { + c, err := commitIter.Next() + if errors.Is(err, io.EOF) { + break + } else if err != nil { + return nil, errors.Wrap(err, "iterate through commits") + } + historyItem := &HistoryItem{ + Description: c.Message, + Author: Author{Name: c.Author.Name, Email: c.Author.Email}, + ConfigContents: make(map[string][]string), + CommitSHA: c.Hash.String(), + Timestamp: c.Author.When, + } + // Identify changed files -> configs + parent, err := c.Parent(0) + if errors.Is(err, object.ErrParentNotFound) { // No parent (initial commit in repo) + break + } else if err != nil { + return nil, errors.Wrap(err, "get parent commit") + } + patch, err := c.Patch(parent) + if err != nil { + return nil, errors.Wrapf(err, "check patch between %v and parent %v", c.Hash.String(), parent.Hash.String()) + } + fps := patch.FilePatches() + // Iterate over file patches, identifying touched files + // Also check if commit matches config filter - only include in returned history if so + // NOTE: Based on assumption that all config files are located in {namespace}/{config}.star + include := pathFilterFn == nil + for _, fp := range fps { + from, to := fp.Files() + // NOTE: renames/moves are not handled here + var configPath string + if from != nil && strings.HasSuffix(from.Path(), ".star") { + configPath = from.Path() + } + if to != nil && configPath == "" && strings.HasSuffix(to.Path(), ".star") { + configPath = to.Path() + } + if configPath != "" { + // namespace/config.star -> namespace/, config.star + namespaceSlash, configFileName := filepath.Split(configPath) + namespace := namespaceSlash[:len(namespaceSlash)-1] + // Remove .star suffix + historyItem.ConfigContents[namespace] = append(historyItem.ConfigContents[namespace], configFileName[:len(configFileName)-5]) + if pathFilterFn != nil { + include = include || pathFilterFn(configPath) + } + } + } + if include { + // Skip if not in requested range (TODO: a sha token-based pagination is probably better) + if i >= offset { + coAuthorName, coAuthorEmail := GetCoauthorInformation(c.Message) + if len(coAuthorEmail) > 0 { + historyItem.CoAuthors = append(historyItem.CoAuthors, Author{Name: coAuthorName, Email: coAuthorEmail}) + } + history = append(history, historyItem) + } + i++ + } + } + return history, nil +} + func (r *repository) mirror(ctx context.Context, ap AuthProvider, url string) error { ref := plumbing.NewBranchReferenceName(r.DefaultBranchName()) remote, err := r.repo.CreateRemote(&config.RemoteConfig{ diff --git a/pkg/repo/repo_test.go b/pkg/repo/repo_test.go index 5e2d95db..e236a668 100644 --- a/pkg/repo/repo_test.go +++ b/pkg/repo/repo_test.go @@ -16,6 +16,7 @@ package repo import ( "context" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -41,3 +42,51 @@ func TestGetCommitSignatureNoGitHub(t *testing.T) { assert.Equal(t, lekkoUser, sign.Email) assert.Equal(t, "test", sign.Name) } + +func TestGetCoauthorInformation(t *testing.T) { + for i, tc := range []struct { + commitMsg string + expectedName string + expectedEmail string + }{ + { + commitMsg: "commit (#464)", + }, + { + commitMsg: "test commit (#51)\n\nCo-authored-by: lekko-app[bot] <108442683+lekko-app[bot]@users.noreply.github.com>\nCo-authored-by: Average Joe <12345+joe@users.noreply.github.com>", + expectedName: "Average Joe", + expectedEmail: "12345+joe@users.noreply.github.com", + }, + { + commitMsg: "test commit (#51)\n\nCo-authored-by: lekko-app[bot] <108442683+lekko-app[bot]@users.noreply.github.com>", + }, + { + commitMsg: "test commit (#51)\n\nCo-authored-by: Average Joe <12345+joe@users.noreply.github.com>", + expectedName: "Average Joe", + expectedEmail: "12345+joe@users.noreply.github.com", + }, + { + commitMsg: "test commit (#51)\n\nother unrelated things", + }, + { + commitMsg: "test commit (#51)\n\nCo-authored-by: lekko-app[bot] <108442683+lekko-app[bot]@users.noreply.github.com>\nCo-authored-by: Average Joe <12345+joe@users.noreply.github.com>\nCo-authored-by: Steve <12345+steve@users.noreply.github.com>", + expectedName: "Average Joe", + expectedEmail: "12345+joe@users.noreply.github.com", + }, + { + commitMsg: "test commit (#51)\n\nCo-authored-by: Steve <12345+steve@users.noreply.github.com>", + expectedName: "Steve", + expectedEmail: "12345+steve@users.noreply.github.com", + }, + { + commitMsg: "test commit (#51)\n\nCo-authored-by: Steve", + expectedName: "Steve", + }, + } { + t.Run(fmt.Sprintf("%d|%s", i, tc.commitMsg), func(t *testing.T) { + name, email := GetCoauthorInformation(tc.commitMsg) + assert.Equal(t, tc.expectedName, name) + assert.Equal(t, tc.expectedEmail, email) + }) + } +}