Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Calculate OSS-experience #145

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ test/resolve/testdata/gradle/*/**
**.gradle-init-script.debricked.groovy
test/resolve/testdata/gradle/gradle.debricked.lock
/mvnproj/target
**debricked.experience.json
52 changes: 52 additions & 0 deletions internal/cmd/experience/experience.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package experience

import (
"github.com/debricked/cli/internal/experience"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)

const (
ExclusionFlag = "exclusion-experience"
)

func NewExperienceCmd(experienceCalculator experience.IExperience) *cobra.Command {

short := "Experience calculator uses git blame and call graphs to calculate who has written code with what open source. [beta feature]"
cmd := &cobra.Command{
Use: "xp [path]",
Short: short,
Hidden: false,
Long: short, //TODO: Add long description

Check failure on line 20 in internal/cmd/experience/experience.go

View workflow job for this annotation

GitHub Actions / Lint

internal/cmd/experience/experience.go:20: Line contains TODO/BUG/FIXME: "TODO: Add long description" (godox)
PreRun: func(cmd *cobra.Command, _ []string) {
_ = viper.BindPFlags(cmd.Flags())
},
RunE: RunE(experienceCalculator),
}

viper.MustBindEnv(ExclusionFlag)

return cmd
}

func RunE(e experience.IExperience) func(_ *cobra.Command, args []string) error {
return func(_ *cobra.Command, args []string) error {
path := ""
if len(args) > 0 {
path = args[0]
}

output, err := e.CalculateExperience(path, viper.GetStringSlice(ExclusionFlag))

if err != nil {
return err
}

err = output.ToFile(experience.OutputFileNameExperience)
if err != nil {
return err
}

return nil
}
}
1 change: 1 addition & 0 deletions internal/cmd/experience/experience_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package experience
2 changes: 2 additions & 0 deletions internal/cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package root

import (
"github.com/debricked/cli/internal/cmd/callgraph"
"github.com/debricked/cli/internal/cmd/experience"
"github.com/debricked/cli/internal/cmd/files"
"github.com/debricked/cli/internal/cmd/fingerprint"
"github.com/debricked/cli/internal/cmd/report"
Expand Down Expand Up @@ -47,6 +48,7 @@ Read more: https://portal.debricked.com/administration-47/how-do-i-generate-an-a
rootCmd.AddCommand(fingerprint.NewFingerprintCmd(container.Fingerprinter()))
rootCmd.AddCommand(resolve.NewResolveCmd(container.Resolver()))
rootCmd.AddCommand(callgraph.NewCallgraphCmd(container.CallgraphGenerator()))
rootCmd.AddCommand(experience.NewExperienceCmd(container.Expereince()))

rootCmd.CompletionOptions.DisableDefaultCmd = true

Expand Down
69 changes: 69 additions & 0 deletions internal/experience/experience.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package experience

import (
"encoding/json"
"log"
"os"

"github.com/debricked/cli/internal/file"
"github.com/debricked/cli/internal/git"
"github.com/debricked/cli/internal/tui"
)

var OutputFileNameExperience = "debricked.experience.json"

type IExperience interface {
CalculateExperience(rootPath string, exclusions []string) (*Experiences, error)
}

type ExperienceCalculator struct {
finder file.IFinder
spinnerManager tui.ISpinnerManager
}

func NewExperience(finder file.IFinder) *ExperienceCalculator {
return &ExperienceCalculator{
finder: finder,
spinnerManager: tui.NewSpinnerManager("Calculating OSS-Experience", "0"),
}
}

func (e *ExperienceCalculator) CalculateExperience(rootPath string, exclusions []string) (*Experiences, error) {

repo, repoErr := git.FindRepository(rootPath)
if repoErr != nil {
return nil, repoErr
}

blamer := git.NewBlamer(repo)

blames, err := blamer.BlamAllFiles()
if err != nil {
return nil, err
}

log.Println("Blamed files: ", len(blames.Files))
blames.ToFile("blames.txt")

Check failure on line 46 in internal/experience/experience.go

View workflow job for this annotation

GitHub Actions / Lint

Error return value of `blames.ToFile` is not checked (errcheck)
return nil, nil

Check failure on line 47 in internal/experience/experience.go

View workflow job for this annotation

GitHub Actions / Lint

return both the `nil` error and invalid value: use a sentinel error instead (nilnil)
}

type Experience struct {
Author string `json:"author"`
Email string `json:"email"`
Count int `json:"count"`
Symbol string `json:"symbol"`
}

type Experiences struct {
Entries []Experience `json:"experiences"`
}

func (f *Experiences) ToFile(ouputFile string) error {
file, err := os.Create(ouputFile)
if err != nil {
return err
}
defer file.Close()

return json.NewEncoder(file).Encode(f)
}
16 changes: 16 additions & 0 deletions internal/file/exclusion.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,19 @@

return false
}

func InclusionsExperience() []string {
return []string{
".go",
".java",
}
}

func Included(inclusions []string, path string) bool {
for _, ex := range inclusions {
if strings.HasSuffix(path, ex) {
return true
}
}
return false

Check failure on line 77 in internal/file/exclusion.go

View workflow job for this annotation

GitHub Actions / Lint

return with no blank line before (nlreturn)
}
19 changes: 19 additions & 0 deletions internal/file/exclusion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,22 @@ func TestExclude(t *testing.T) {
})
}
}

func TestIncluded(t *testing.T) {
inclusions := InclusionsExperience()
testCases := []struct {
path string
expected bool
}{
{"foo/bar/test.go", true},
{"test.go", true},
{"foo/bar/test.txt", false},
}

for _, tc := range testCases {
result := Included(inclusions, tc.path)
if result != tc.expected {
t.Errorf("Included(%q) = %v; want %v", tc.path, result, tc.expected)
}
}
}
181 changes: 181 additions & 0 deletions internal/git/blame.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package git

import (
"bufio"
"bytes"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"

"github.com/debricked/cli/internal/file"
"github.com/go-git/go-git/v5"
)

type IBlamer interface {
Blame(path string) (*BlameFile, error)
}

type Blamer struct {
repository *git.Repository
inclusions []string
}

func NewBlamer(repository *git.Repository) *Blamer {
return &Blamer{
repository: repository,
inclusions: file.InclusionsExperience(),
}
}

type BlameFiles struct {
Files []BlameFile
}

func (b *BlameFiles) ToFile(outputFile string) error {

file, err := os.Create(outputFile)
if err != nil {
return err
}
defer file.Close()

for _, blameFile := range b.Files {
for _, line := range blameFile.Lines {
_, err := file.WriteString(fmt.Sprintf("%s,%d,%s,%s\n", blameFile.Path, line.LineNumber, line.Author.Name, line.Author.Email))
if err != nil {
return err
}
}
}

return nil
}

type BlameFile struct {
Lines []BlameLine
Path string
}

type BlameLine struct {
Author Author
LineNumber int
}

type Author struct {
Email string
Name string
}

// gitBlameFile runs `git blame --line-porcelain` on the given file and parses the output to populate a slice of BlameLine.
func gitBlameFile(filePath string) ([]BlameLine, error) {
cmd := exec.Command("git", "blame", "--line-porcelain", filePath)

var out bytes.Buffer
cmd.Stdout = &out

err := cmd.Run()
if err != nil {
return nil, err
}

scanner := bufio.NewScanner(&out)
var blameLines []BlameLine
var currentBlame BlameLine

lineNumber := 1
for scanner.Scan() {
line := scanner.Text()
switch {
case strings.HasPrefix(line, "author "):
currentBlame.Author.Name = strings.TrimPrefix(line, "author ")
case strings.HasPrefix(line, "author-mail "):
currentBlame.Author.Email = strings.Trim(strings.TrimPrefix(line, "author-mail "), "<>")

case strings.HasPrefix(line, "filename "):

// End of the current commit block
currentBlame.LineNumber = lineNumber
blameLines = append(blameLines, currentBlame) // Add the populated BlameLine to the slice.
currentBlame = BlameLine{} // Reset for the next block.
lineNumber += 1
}
}

if err := scanner.Err(); err != nil {
return nil, err
}

return blameLines, nil
}

func (b *Blamer) BlamAllFiles() (*BlameFiles, error) {
files, err := FindAllTrackedFiles(b.repository)
if err != nil {
return nil, err
}

blameFiles := make([]BlameFile, 0)

blameFileChan := make(chan BlameFile, len(files))
errChan := make(chan error, len(files))

w, err := b.repository.Worktree()
if err != nil {
log.Fatalf("Could not get workdir: %v", err)
}

root := w.Filesystem.Root()

var wg sync.WaitGroup
for _, fileBlame := range files {

// Add the root path to the file path
fileBlameAbsPath := filepath.Join(root, fileBlame)

if !file.Included(b.inclusions, fileBlame) {
continue
}

wg.Add(1)
go func(fileBlame string) {
defer wg.Done()

blameLines, err := gitBlameFile(fileBlameAbsPath)

if err != nil {
errChan <- err
return

Check failure on line 151 in internal/git/blame.go

View workflow job for this annotation

GitHub Actions / Lint

return with no blank line before (nlreturn)
}

blameFile := BlameFile{
Lines: blameLines,
Path: fileBlame,
}

blameFileChan <- blameFile
}(fileBlame)
}

wg.Wait()
close(blameFileChan)
close(errChan)

for bf := range blameFileChan {
blameFiles = append(blameFiles, bf)
}

for err := range errChan {
if err != nil {
return nil, err
}
}

return &BlameFiles{
Files: blameFiles,
}, nil

}
Loading
Loading