Skip to content

Commit

Permalink
Add local get history based on git log (#268)
Browse files Browse the repository at this point in the history
* Add local get history based on git log

* Add ignore for false positive lint issue

* Fix offset

* Remove lint ignore rule
  • Loading branch information
DavidSGK authored Dec 16, 2023
1 parent 9faa061 commit 71465f1
Show file tree
Hide file tree
Showing 3 changed files with 195 additions and 2 deletions.
4 changes: 2 additions & 2 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ linters-settings:
check-type-assertions: true
forbidigo:
forbid:
- '^print$'
- '^println$'
- "^print$"
- "^println$"
linters:
enable:
- asciicheck
Expand Down
144 changes: 144 additions & 0 deletions pkg/repo/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ package repo
import (
"context"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"
"time"

Expand All @@ -42,13 +44,29 @@ import (
const (
RemoteName = "origin"
backoffMaxElapsed = 7 * time.Second
// Username for LekkoApp bot.
LekkoAppUser = "lekko-app[bot]"
)

var (
ErrMissingCredentials = fmt.Errorf("missing credentials")
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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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: <coauthor_name> <coauthor_email>`.
// 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
Expand Down Expand Up @@ -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{
Expand Down
49 changes: 49 additions & 0 deletions pkg/repo/repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package repo

import (
"context"
"fmt"
"testing"

"github.com/stretchr/testify/assert"
Expand All @@ -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)
})
}
}

0 comments on commit 71465f1

Please sign in to comment.