Files
git-velocity/internal/app/app.go
T

388 lines
12 KiB
Go

package app
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/lukaszraczylo/git-velocity/internal/aggregator"
"github.com/lukaszraczylo/git-velocity/internal/config"
"github.com/lukaszraczylo/git-velocity/internal/domain/models"
"github.com/lukaszraczylo/git-velocity/internal/domain/scoring"
"github.com/lukaszraczylo/git-velocity/internal/generator/site"
"github.com/lukaszraczylo/git-velocity/internal/git"
"github.com/lukaszraczylo/git-velocity/internal/github"
)
// App is the main application orchestrator
type App struct {
config *config.Config
outputDir string
verbose bool
client *github.Client
gitRepo *git.Repository
}
// New creates a new application instance
func New(configPath, outputDir string, verbose bool) (*App, error) {
// Load configuration
cfg, err := config.Load(configPath)
if err != nil {
return nil, fmt.Errorf("failed to load configuration: %w", err)
}
return &App{
config: cfg,
outputDir: outputDir,
verbose: verbose,
}, nil
}
// Run executes the main application workflow
func (a *App) Run(ctx context.Context) error {
startTime := time.Now()
a.log("Starting Git Velocity analysis...")
// Initialize GitHub client
a.log("Initializing GitHub client...")
client, err := github.NewClient(ctx, a.config)
if err != nil {
return fmt.Errorf("failed to create GitHub client: %w", err)
}
a.client = client
// Set up progress callback
client.SetProgressCallback(func(msg string) {
a.log("%s", msg)
})
// Initialize local git repository manager if using local git
if a.config.Options.UseLocalGit {
a.log("Initializing local git repository manager...")
gitRepo, err := git.NewRepository(a.config.Options.CloneDirectory)
if err != nil {
return fmt.Errorf("failed to create git repository manager: %w", err)
}
gitRepo.SetProgressCallback(func(msg string) {
a.log("%s", msg)
})
a.gitRepo = gitRepo
}
// Parse date range
dateRange, err := a.config.GetParsedDateRange()
if err != nil {
return fmt.Errorf("failed to parse date range: %w", err)
}
// Collect data from all repositories
a.log("Fetching data from repositories...")
rawData, err := a.collectData(ctx, dateRange)
if err != nil {
return fmt.Errorf("failed to collect data: %w", err)
}
a.log("Collected %d commits, %d PRs, %d reviews, %d issues",
len(rawData.Commits), len(rawData.PullRequests), len(rawData.Reviews), len(rawData.Issues))
// Fetch user profiles for better deduplication
// This gets public emails and names from GitHub profiles to help match commit authors
a.log("Fetching user profiles for deduplication...")
userProfiles, err := a.fetchUserProfiles(ctx, rawData)
if err != nil {
a.log("Warning: failed to fetch some user profiles: %v", err)
// Continue anyway, deduplication will still work with other methods
}
a.log("Fetched %d user profiles", len(userProfiles))
// Aggregate metrics
a.log("Aggregating metrics...")
agg := aggregator.New(a.config)
agg.SetUserProfiles(userProfiles)
globalMetrics, err := agg.Aggregate(rawData, dateRange)
if err != nil {
return fmt.Errorf("failed to aggregate metrics: %w", err)
}
// Calculate scores
if a.config.Scoring.Enabled {
a.log("Calculating scores and achievements...")
scorer := scoring.NewCalculator(a.config)
globalMetrics = scorer.Calculate(globalMetrics)
}
// Generate the site
a.log("Generating static site...")
gen, err := site.NewGenerator(a.outputDir, a.config)
if err != nil {
return fmt.Errorf("failed to create site generator: %w", err)
}
if err := gen.Generate(globalMetrics); err != nil {
return fmt.Errorf("failed to generate site: %w", err)
}
duration := time.Since(startTime)
a.log("Analysis complete! Dashboard generated in %s", a.outputDir)
a.log("Total time: %s", duration.Round(time.Millisecond))
return nil
}
func (a *App) collectData(ctx context.Context, dateRange *config.ParsedDateRange) (*models.RawData, error) {
data := &models.RawData{}
for _, repo := range a.config.Repositories {
if repo.Pattern != "" {
// Pattern-based repository selection (e.g., "org/*")
repos, err := a.client.ListOrgRepos(ctx, repo.Owner, repo.Pattern)
if err != nil {
return nil, fmt.Errorf("failed to list repos for %s/%s: %w", repo.Owner, repo.Pattern, err)
}
for _, r := range repos {
if err := a.collectRepoData(ctx, repo.Owner, r, dateRange, data); err != nil {
a.log("Warning: failed to collect data for %s/%s: %v", repo.Owner, r, err)
// Continue with other repos
}
}
} else {
// Single repository
if err := a.collectRepoData(ctx, repo.Owner, repo.Name, dateRange, data); err != nil {
return nil, fmt.Errorf("failed to collect data for %s/%s: %w", repo.Owner, repo.Name, err)
}
}
}
return data, nil
}
func (a *App) collectRepoData(ctx context.Context, owner, name string, dateRange *config.ParsedDateRange, data *models.RawData) error {
repoName := fmt.Sprintf("%s/%s", owner, name)
a.log(" Fetching data from %s...", repoName)
// Fetch commits - use local git if enabled (much faster)
var commits []models.Commit
var err error
if a.gitRepo != nil {
// Clone/update repository locally
token := a.config.Auth.GithubToken
// Determine clone options (shallow clone if enabled)
var cloneOpts *git.CloneOptions
if a.config.Options.ShallowClone && dateRange.Start != nil {
// Get commit count since start date to determine shallow clone depth
commitCount, countErr := a.client.GetCommitCountSince(ctx, owner, name, *dateRange.Start)
if countErr != nil {
a.log(" Warning: failed to get commit count for shallow clone: %v", countErr)
// Proceed with full clone
} else if commitCount > 0 {
// Add buffer for safety margin
depth := commitCount + a.config.Options.ShallowCloneBuffer
cloneOpts = &git.CloneOptions{Depth: depth}
a.log(" Using shallow clone (depth: %d = %d commits + %d buffer)", depth, commitCount, a.config.Options.ShallowCloneBuffer)
}
}
cloneErr := a.gitRepo.EnsureClonedWithOptions(ctx, owner, name, token, cloneOpts)
if cloneErr != nil {
a.log(" Warning: failed to clone repository locally, falling back to API: %v", cloneErr)
// Fallback to API
commits, err = a.client.FetchCommits(ctx, owner, name, dateRange.Start, dateRange.End)
} else {
// Use local git for commits
commits, err = a.gitRepo.FetchCommits(ctx, owner, name, dateRange.Start, dateRange.End)
}
} else {
// Use API for commits
commits, err = a.client.FetchCommits(ctx, owner, name, dateRange.Start, dateRange.End)
}
if err != nil {
return fmt.Errorf("failed to fetch commits: %w", err)
}
// Filter out bots
for _, c := range commits {
if !a.config.IsBot(c.Author.Login) {
data.Commits = append(data.Commits, c)
}
}
// Fetch pull requests and reviews
// Use GraphQL if available (much fewer API calls), otherwise fall back to REST
if a.client.HasGraphQL() {
prs, reviews, err := a.client.FetchPRsWithReviewsGraphQL(ctx, owner, name, dateRange.Start, dateRange.End)
if err != nil {
a.log(" Warning: GraphQL fetch failed, falling back to REST: %v", err)
// Fall back to REST
prs, reviews, err = a.fetchPRsAndReviewsREST(ctx, owner, name, dateRange, data)
if err != nil {
return err
}
}
// Filter out bots
for _, pr := range prs {
if !a.config.IsBot(pr.Author.Login) {
data.PullRequests = append(data.PullRequests, pr)
}
}
for _, r := range reviews {
if !a.config.IsBot(r.Author.Login) {
data.Reviews = append(data.Reviews, r)
}
}
} else {
// Use REST API
if _, _, err := a.fetchPRsAndReviewsREST(ctx, owner, name, dateRange, data); err != nil {
return err
}
}
// Fetch issues and comments
// Use GraphQL if available (much fewer API calls), otherwise fall back to REST
if a.client.HasGraphQL() {
issues, comments, err := a.client.FetchIssuesWithCommentsGraphQL(ctx, owner, name, dateRange.Start, dateRange.End)
if err != nil {
a.log(" Warning: GraphQL fetch failed, falling back to REST: %v", err)
// Fall back to REST
if err := a.fetchIssuesAndCommentsREST(ctx, owner, name, dateRange, data); err != nil {
return err
}
} else {
// Filter out bots
for _, issue := range issues {
if !a.config.IsBot(issue.Author.Login) {
data.Issues = append(data.Issues, issue)
}
}
for _, comment := range comments {
if !a.config.IsBot(comment.Author.Login) {
data.IssueComments = append(data.IssueComments, comment)
}
}
}
} else {
// Use REST API
if err := a.fetchIssuesAndCommentsREST(ctx, owner, name, dateRange, data); err != nil {
return err
}
}
return nil
}
func (a *App) log(format string, args ...interface{}) {
if a.verbose {
log.Printf(format, args...)
} else {
fmt.Fprintf(os.Stderr, format+"\n", args...)
}
}
// fetchUserProfiles collects unique GitHub logins from PR/review data and fetches their profiles
// The profiles contain public emails and names that help with commit author deduplication
func (a *App) fetchUserProfiles(ctx context.Context, data *models.RawData) (map[string]aggregator.UserProfile, error) {
// Collect unique logins from PRs and reviews
loginSet := make(map[string]bool)
for _, pr := range data.PullRequests {
if pr.Author.Login != "" {
loginSet[pr.Author.Login] = true
}
}
for _, review := range data.Reviews {
if review.Author.Login != "" {
loginSet[review.Author.Login] = true
}
}
// Convert to slice
logins := make([]string, 0, len(loginSet))
for login := range loginSet {
logins = append(logins, login)
}
if len(logins) == 0 {
return make(map[string]aggregator.UserProfile), nil
}
// Fetch profiles from GitHub (uses cache)
ghProfiles, err := a.client.FetchUserProfiles(ctx, logins)
if err != nil {
return nil, err
}
// Convert to aggregator.UserProfile
profiles := make(map[string]aggregator.UserProfile)
for login, p := range ghProfiles {
profiles[login] = aggregator.UserProfile{
ID: p.ID,
Login: p.Login,
Name: p.Name,
Email: p.Email,
AvatarURL: p.AvatarURL,
}
}
return profiles, nil
}
// fetchPRsAndReviewsREST fetches PRs and reviews using the REST API (fallback when GraphQL fails)
func (a *App) fetchPRsAndReviewsREST(ctx context.Context, owner, name string, dateRange *config.ParsedDateRange, data *models.RawData) ([]models.PullRequest, []models.Review, error) {
prs, err := a.client.FetchPullRequests(ctx, owner, name, dateRange.Start, dateRange.End)
if err != nil {
return nil, nil, fmt.Errorf("failed to fetch pull requests: %w", err)
}
a.log(" Found %d pull requests", len(prs))
// Fetch reviews for each PR
var reviews []models.Review
for _, pr := range prs {
prReviews, err := a.client.FetchReviews(ctx, owner, name, pr.Number)
if err != nil {
a.log(" Warning: failed to fetch reviews for PR #%d: %v", pr.Number, err)
continue
}
reviews = append(reviews, prReviews...)
}
a.log(" Found %d reviews (REST)", len(reviews))
return prs, reviews, nil
}
// fetchIssuesAndCommentsREST fetches issues and comments using the REST API (fallback when GraphQL fails)
func (a *App) fetchIssuesAndCommentsREST(ctx context.Context, owner, name string, dateRange *config.ParsedDateRange, data *models.RawData) error {
issues, err := a.client.FetchIssues(ctx, owner, name, dateRange.Start, dateRange.End)
if err != nil {
return fmt.Errorf("failed to fetch issues: %w", err)
}
a.log(" Found %d issues", len(issues))
// Filter out bots and add to data
for _, issue := range issues {
if !a.config.IsBot(issue.Author.Login) {
data.Issues = append(data.Issues, issue)
}
}
// Fetch all comments for the repository within date range
comments, err := a.client.FetchIssueComments(ctx, owner, name, dateRange.Start, dateRange.End)
if err != nil {
a.log(" Warning: failed to fetch issue comments: %v", err)
} else {
for _, comment := range comments {
if !a.config.IsBot(comment.Author.Login) {
data.IssueComments = append(data.IssueComments, comment)
}
}
a.log(" Found %d issue comments (REST)", len(comments))
}
return nil
}