From ca91a162ae8f80ed13c9ba960855fbbdeffd62df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erdem=20K=C3=B6=C5=9Fk?= Date: Tue, 21 Jan 2025 01:36:23 +0300 Subject: [PATCH] [feat] Adding duration support on providers --- README.md | 141 ++++++------ internal/analyzer/analyzer.go | 37 +++- internal/models/models.go | 2 + internal/output/console.go | 18 ++ internal/output/formatter.go | 3 - internal/output/json.go | 4 + internal/output/md.go | 17 ++ internal/providers/bitbucket.go | 25 ++- internal/providers/github.go | 166 +++++++++----- internal/providers/provider.go | 2 +- internal/util/url_parser.go | 44 ++++ main.go | 7 +- test.md | 372 ++++++++++++++++++++++++++++++++ 13 files changed, 699 insertions(+), 139 deletions(-) create mode 100644 test.md diff --git a/README.md b/README.md index e4f3acb..4ed1d37 100644 --- a/README.md +++ b/README.md @@ -6,36 +6,90 @@ Gitness - Your repo's fitness witness! Track your bus factor before your code mi ## Features -- Calculate repository bus factor +- Calculate repository bus factor and knowledge distribution - Analyze contributor statistics and activity patterns - Track recent contributor engagement - Support for multiple VCS providers (GitHub, Bitbucket) - Multiple output formats (Console, JSON, Markdown) +- Configurable analysis period (e.g., 6m, 1y, 30d) - CI/CD pipeline integration - Docker support +## Usage + +```bash +# Analyze all time +gitness https://github.com/user/repo + +# Analyze last 6 months +gitness --duration 6m https://github.com/user/repo + +# Analyze last 1 year with JSON output +gitness --duration 1y --output json https://github.com/user/repo + +# Analyze last 30 days +gitness --duration 30d https://github.com/user/repo +``` + ## Metrics Explained ### Bus Factor 🚌 -The "Bus Factor" represents the minimum number of developers that would need to be hit by a bus (or win the lottery) before the project is in serious trouble. It's calculated based on the number of contributors who collectively account for 80% of the project's contributions. +The "Bus Factor" represents the minimum number of developers that would need to be hit by a bus before the project is in serious trouble. It's calculated based on contributors who collectively account for 80% of contributions. - 🔴 Critical (< 2): Project knowledge is dangerously concentrated - 🟡 Warning (2-3): Limited knowledge distribution - 🟢 Good (≥ 4): Healthy knowledge distribution +### Knowledge Distribution Score 📊 +Measures how evenly the knowledge is distributed across all contributors (0-100). + +- 🔴 Critical (< 25): Knowledge is heavily concentrated +- 🟡 Warning (25-50): Moderate knowledge concentration +- 🟢 Good (> 50): Well-distributed knowledge + ### Active Contributor Ratio 👥 -Percentage of contributors who have made significant contributions (>1% of total commits). This metric helps identify the real active contributor base versus occasional contributors. +Percentage of contributors who have made significant contributions (>1% of total commits). - 🔴 Critical (< 30%): Most contributors are occasional - 🟡 Warning (30-50%): Moderate active participation -- 🟢 Good (≥ 50%): Healthy active community +- 🟢 Good (> 50%): Healthy active participation + +### Recent Contributors 🕒 +Number of contributors active in last 3 months. + +- 🔴 Critical (< 2): Low recent activity +- 🟡 Warning (2-4): Moderate recent activity +- 🟢 Good (≥ 5): High recent activity -### Recent Contributors 📅 -Number of unique contributors who have made commits in the last 3 months. This metric helps assess the current activity level and project momentum. +## Example Output -- 🔴 Critical (< 2): Project might be stagnating -- 🟡 Warning (2-4): Limited recent activity -- 🟢 Good (≥ 5): Active development +``` +Repository Analysis: user/repo +Analysis Period: Last 6 months + +🟢 Bus Factor: 4 (critical if < 2, warning if < 4) +🟢 Knowledge Distribution Score: 75.5 (0-100, higher is better) +🟡 Active Contributor Ratio: 45.5% (critical if < 30%, warning if < 50%) +🟢 Recent Contributors: 6 (active in last 3 months) + +Total Commits: 330 + +Contributors: +------------------ +John Doe: 100 commits (30.3%) +Jane Smith: 90 commits (27.3%) +Bob Wilson: 80 commits (24.2%) +Alice Brown: 60 commits (18.2%) +``` + +## Environment Variables + +```bash +GITHUB_TOKEN=your_token +BITBUCKET_CLIENT_ID=your_client_id +BITBUCKET_CLIENT_SECRET=your_client_secret +COMMIT_HISTORY_DURATION=6m # Optional: 6m, 1y, 30d etc. +``` ## Installation @@ -51,22 +105,6 @@ docker run \ -e REPOSITORY_URL="https://github.com/user/repo" \ gitness --output json; ``` -## Usage - -### Command Line -```bash -gitness https://github.com/user/repo -``` -### With output format -```bash -gitness --output json https://github.com/user/repo -``` -### Using environment variables -```bash -export REPOSITORY_URL="https://github.com/user/repo" -export GITHUB_TOKEN="your_token" -gitness -``` ### Environment Variables @@ -160,59 +198,6 @@ jobs: [Example GitHub Action Run](https://github.com/erdemkosk/stock-exchange/actions/runs/12857735422) - - -## Output Examples - -### Console (default) -``` -Repo: user/repo -Bus Factor: 🟢 3 (critical if < 2, warning if < 4) -Active Contributor Ratio: 🟡 45.5% (critical if < 30%, warning if < 50%) -Recent Contributors: 🔴 1 (active in last 3 months) - -Contributors: ------------------- -John Doe: 150 commits (45.5%) -Jane Smith: 100 commits (30.3%) -Bob Johnson: 80 commits (24.2%) -``` - -### JSON -```json -{ - "owner": "user", - "repo": "repo", - "busFactor": 3, - "totalCommits": 330, - "contributorActivity": 45.5, - "recentContributors": 1, - "contributors": [ - { - "name": "John Doe", - "commits": 150, - "percentage": 45.5 - } - ] -} -``` - -### Markdown -```markdown -# Repository Analysis: user/repo - -## 🟢 Bus Factor: **3** (critical if < 2, warning if < 4) -## 🟡 Active Contributor Ratio: **45.5%** (critical if < 30%, warning if < 50%) -## 🔴 Recent Contributors: **1** (active in last 3 months) - -Total Commits: 330 - -## Contributors - -| Name | Commits | Percentage | -|------|---------|------------| -| John Doe | 150 | 45.5% | -``` ## Architecture - Clean architecture principles diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index 22cce96..a85ab1d 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -2,6 +2,7 @@ package analyzer import ( "fmt" + "math" "sort" "time" @@ -22,12 +23,12 @@ func NewRepositoryAnalyzer(provider providers.CommitProvider) *RepositoryAnalyze } } -func (ra *RepositoryAnalyzer) Analyze(owner, repo string) (*models.RepositoryStats, error) { +func (ra *RepositoryAnalyzer) Analyze(owner, repo string, duration string) (*models.RepositoryStats, error) { if owner == "" || repo == "" { return nil, fmt.Errorf("owner and repo cannot be empty") } - stats, err := ra.provider.FetchCommits(owner, repo) + stats, err := ra.provider.FetchCommits(owner, repo, duration) if err != nil { return nil, fmt.Errorf("failed to fetch commits: %w", err) } @@ -72,6 +73,9 @@ func (ra *RepositoryAnalyzer) Analyze(owner, repo string) (*models.RepositorySta contributorActivity := float64(activeContributors) / float64(len(contributors)) * 100 + // Knowledge Distribution Score hesaplama + knowledgeScore := calculateKnowledgeDistribution(stats) + return &models.RepositoryStats{ Owner: owner, Repo: repo, @@ -80,6 +84,8 @@ func (ra *RepositoryAnalyzer) Analyze(owner, repo string) (*models.RepositorySta TotalCommits: total, ContributorActivity: contributorActivity, RecentContributors: recentContributors, + KnowledgeScore: knowledgeScore, + AnalysisDuration: duration, }, nil } @@ -120,3 +126,30 @@ func calculateBusFactor(stats map[string]providers.CommitInfo) int { return busFactor } + +func calculateKnowledgeDistribution(stats map[string]providers.CommitInfo) float64 { + if len(stats) == 0 { + return 0 + } + + var total int + for _, info := range stats { + total += info.Count + } + + var sumDifferences float64 + var n = float64(len(stats)) + + for _, info1 := range stats { + percent1 := float64(info1.Count) / float64(total) + for _, info2 := range stats { + percent2 := float64(info2.Count) / float64(total) + sumDifferences += math.Abs(percent1 - percent2) + } + } + + gini := sumDifferences / (2 * n * n) + knowledgeScore := (1 - gini) * 100 + + return math.Round(knowledgeScore*100) / 100 +} diff --git a/internal/models/models.go b/internal/models/models.go index bfe2a57..ffae7d4 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -31,6 +31,8 @@ type RepositoryStats struct { TotalCommits int ContributorActivity float64 RecentContributors int + KnowledgeScore float64 + AnalysisDuration string } func (rs *RepositoryStats) Print() { diff --git a/internal/output/console.go b/internal/output/console.go index 093b527..2ed94ee 100644 --- a/internal/output/console.go +++ b/internal/output/console.go @@ -26,6 +26,12 @@ func (f *ConsoleFormatter) Format(stats *models.RepositoryStats) (string, error) output.WriteString(fmt.Sprintf("\nRepo: %s/%s\n", stats.Owner, stats.Repo)) + if stats.AnalysisDuration != "" { + output.WriteString(fmt.Sprintf("\nAnalysis Period: Last %s\n", stats.AnalysisDuration)) + } else { + output.WriteString("\nAnalysis Period: All Time\n") + } + // ANSI color codes red := "\033[31m" yellow := "\033[33m" @@ -59,6 +65,9 @@ func (f *ConsoleFormatter) Format(stats *models.RepositoryStats) (string, error) output.WriteString(fmt.Sprintf("Bus Factor: %s%d%s (critical if < 2, warning if < 4)\n", busFactorColor, stats.BusFactor, reset)) + output.WriteString(fmt.Sprintf("Knowledge Distribution Score: %s%.1f%s (0-100, higher is better)\n", + getScoreColor(stats.KnowledgeScore), stats.KnowledgeScore, reset)) + output.WriteString(fmt.Sprintf("Active Contributor Ratio: %s%.1f%%%s (contributors with >1%% contribution, critical if < 30%%, warning if < 50%%)\n", activityColor, stats.ContributorActivity, reset)) @@ -75,3 +84,12 @@ func (f *ConsoleFormatter) Format(stats *models.RepositoryStats) (string, error) return output.String(), nil } + +func getScoreColor(score float64) string { + if score < 25 { + return "\033[31m" // red + } else if score < 50 { + return "\033[33m" // yellow + } + return "\033[32m" // green +} diff --git a/internal/output/formatter.go b/internal/output/formatter.go index 1024a53..6901b9c 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -1,8 +1,6 @@ package output import ( - "fmt" - "github.com/erdemkosk/gitness/internal/models" ) @@ -32,7 +30,6 @@ func (f *FormatterFactory) Register(name string, formatter Formatter) { } func (f *FormatterFactory) GetFormatter(format string) (Formatter, bool) { - fmt.Println(format) formatter, exists := f.formatters[format] return formatter, exists } diff --git a/internal/output/json.go b/internal/output/json.go index 0e71707..799d7f4 100644 --- a/internal/output/json.go +++ b/internal/output/json.go @@ -17,14 +17,18 @@ func (f *JSONFormatter) Format(stats *models.RepositoryStats) (string, error) { Contributors []models.Contributor `json:"contributors"` ContributorActivity float64 `json:"contributorActivity"` // Percentage of contributors with >1% contribution RecentContributors int `json:"recentContributors"` // Number of contributors active in last 3 months + KnowledgeScore float64 `json:"knowledgeScore"` + AnalysisDuration string `json:"analysisDuration,omitempty"` }{ Owner: stats.Owner, Repo: stats.Repo, BusFactor: stats.BusFactor, + KnowledgeScore: stats.KnowledgeScore, TotalCommits: stats.TotalCommits, Contributors: stats.Contributors, ContributorActivity: stats.ContributorActivity, RecentContributors: stats.RecentContributors, + AnalysisDuration: stats.AnalysisDuration, } jsonData, err := json.MarshalIndent(data, "", " ") diff --git a/internal/output/md.go b/internal/output/md.go index 43b22e2..a1b4630 100644 --- a/internal/output/md.go +++ b/internal/output/md.go @@ -15,6 +15,12 @@ func (f *MarkdownFormatter) Format(stats *models.RepositoryStats) (string, error md.WriteString("![Gitness](https://github.com/erdemkosk/gitness/blob/master/logo.png?raw=true)\n\n") md.WriteString(fmt.Sprintf("# Repository Analysis: %s/%s\n\n", stats.Owner, stats.Repo)) + if stats.AnalysisDuration != "" { + md.WriteString(fmt.Sprintf("## 📅 Analysis Period: Last %s\n\n", stats.AnalysisDuration)) + } else { + md.WriteString("## 📅 Analysis Period: All Time\n\n") + } + // Bus Factor status busFactorEmoji := "🟢" if stats.BusFactor < 2 { @@ -39,9 +45,20 @@ func (f *MarkdownFormatter) Format(stats *models.RepositoryStats) (string, error recentEmoji = "🟡" } + // Knowledge Distribution status + scoreEmoji := "🟢" + if stats.KnowledgeScore < 25 { + scoreEmoji = "🔴" + } else if stats.KnowledgeScore < 50 { + scoreEmoji = "🟡" + } + md.WriteString(fmt.Sprintf("## %s Bus Factor: **%d** (critical if < 2, warning if < 4)\n\n", busFactorEmoji, stats.BusFactor)) + md.WriteString(fmt.Sprintf("## %s Knowledge Distribution Score: **%.1f** (0-100, higher is better)\n\n", + scoreEmoji, stats.KnowledgeScore)) + md.WriteString(fmt.Sprintf("## %s Active Contributor Ratio: **%.1f%%** (contributors with >1%% contribution, critical if < 30%%, warning if < 50%%)\n\n", activityEmoji, stats.ContributorActivity)) diff --git a/internal/providers/bitbucket.go b/internal/providers/bitbucket.go index 02670ea..bf3ac28 100644 --- a/internal/providers/bitbucket.go +++ b/internal/providers/bitbucket.go @@ -9,6 +9,8 @@ import ( "net/url" "strings" "time" + + "github.com/erdemkosk/gitness/internal/util" ) type BitbucketProvider struct { @@ -80,15 +82,22 @@ func getOAuthToken(clientID, clientSecret string) (string, error) { return tokenResp.AccessToken, nil } -func (b *BitbucketProvider) FetchCommits(owner, repo string) (map[string]CommitInfo, error) { - if owner == "" || repo == "" { - return nil, fmt.Errorf("owner and repo cannot be empty") - } - +func (b *BitbucketProvider) FetchCommits(owner, repo string, duration string) (map[string]CommitInfo, error) { authorStats := make(map[string]CommitInfo) pageURL := fmt.Sprintf("https://api.bitbucket.org/2.0/repositories/%s/%s/commits?pagelen=100", owner, repo) + var since *time.Time + if duration != "" { + dur, err := util.ParseDuration(duration) + if err != nil { + return nil, fmt.Errorf("failed to parse duration: %w", err) + } + t := dur.ToTime() + since = &t + pageURL += fmt.Sprintf("&since=%s", t.Format(time.RFC3339)) + } + for pageURL != "" { commits, nextPage, err := b.fetchPage(pageURL) if err != nil { @@ -96,12 +105,16 @@ func (b *BitbucketProvider) FetchCommits(owner, repo string) (map[string]CommitI } for _, commit := range commits { - author := b.extractAuthorName(commit) commitDate, err := time.Parse(time.RFC3339, commit.Date) if err != nil { return nil, fmt.Errorf("failed to parse commit date: %v", err) } + if since != nil && commitDate.Before(*since) { + continue + } + + author := b.extractAuthorName(commit) info := authorStats[author] info.Count++ if commitDate.After(info.LastCommit) { diff --git a/internal/providers/github.go b/internal/providers/github.go index 8848642..d6c3398 100644 --- a/internal/providers/github.go +++ b/internal/providers/github.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/erdemkosk/gitness/internal/util" "github.com/shurcooL/githubv4" "golang.org/x/oauth2" ) @@ -20,65 +21,134 @@ func NewGitHubProvider(token string) *GitHubProvider { } } -func (g *GitHubProvider) FetchCommits(owner, repo string) (map[string]CommitInfo, error) { - var query struct { - Repository struct { - DefaultBranchRef struct { - Target struct { - Commit struct { - History struct { - PageInfo struct { - HasNextPage bool - EndCursor string - } - Nodes []struct { - Author struct { - Name string - Email string +func (g *GitHubProvider) FetchCommits(owner, repo string, duration string) (map[string]CommitInfo, error) { + if duration != "" { + var query struct { + Repository struct { + DefaultBranchRef struct { + Target struct { + Commit struct { + History struct { + PageInfo struct { + HasNextPage bool + EndCursor string } - CommittedDate githubv4.DateTime - } - } `graphql:"history(first: $limit, after: $after)"` - } `graphql:"... on Commit"` + Nodes []struct { + Author struct { + Name string + Email string + } + CommittedDate githubv4.DateTime + } + } `graphql:"history(first: $limit, after: $after, since: $since)"` + } `graphql:"... on Commit"` + } } + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + dur, err := util.ParseDuration(duration) + if err != nil { + return nil, fmt.Errorf("failed to parse duration: %w", err) + } + + variables := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "limit": githubv4.Int(100), + "after": (*githubv4.String)(nil), + "since": githubv4.GitTimestamp{Time: dur.ToTime()}, + } + + authorStats := make(map[string]CommitInfo) + hasNextPage := true + + for hasNextPage { + err := g.client.Query(context.Background(), &query, variables) + if err != nil { + return nil, fmt.Errorf("GitHub query failed: %v", err) } - } `graphql:"repository(owner: $owner, name: $repo)"` - } - variables := map[string]interface{}{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - "limit": githubv4.Int(100), - "after": (*githubv4.String)(nil), - } + for _, commit := range query.Repository.DefaultBranchRef.Target.Commit.History.Nodes { + author := commit.Author.Name + if author == "" { + author = commit.Author.Email + } + info := authorStats[author] + info.Count++ + if commit.CommittedDate.Time.After(info.LastCommit) { + info.LastCommit = commit.CommittedDate.Time + } + authorStats[author] = info + } + + hasNextPage = query.Repository.DefaultBranchRef.Target.Commit.History.PageInfo.HasNextPage + if hasNextPage { + variables["after"] = githubv4.String(query.Repository.DefaultBranchRef.Target.Commit.History.PageInfo.EndCursor) + } + } - authorStats := make(map[string]CommitInfo) - hasNextPage := true + return authorStats, nil + } else { + var query struct { + Repository struct { + DefaultBranchRef struct { + Target struct { + Commit struct { + History struct { + PageInfo struct { + HasNextPage bool + EndCursor string + } + Nodes []struct { + Author struct { + Name string + Email string + } + CommittedDate githubv4.DateTime + } + } `graphql:"history(first: $limit, after: $after)"` + } `graphql:"... on Commit"` + } + } + } `graphql:"repository(owner: $owner, name: $repo)"` + } - for hasNextPage { - err := g.client.Query(context.Background(), &query, variables) - if err != nil { - return nil, fmt.Errorf("GitHub query failed: %v", err) + variables := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "limit": githubv4.Int(100), + "after": (*githubv4.String)(nil), } - for _, commit := range query.Repository.DefaultBranchRef.Target.Commit.History.Nodes { - author := commit.Author.Name - if author == "" { - author = commit.Author.Email + authorStats := make(map[string]CommitInfo) + hasNextPage := true + + for hasNextPage { + err := g.client.Query(context.Background(), &query, variables) + if err != nil { + return nil, fmt.Errorf("GitHub query failed: %v", err) } - info := authorStats[author] - info.Count++ - if commit.CommittedDate.Time.After(info.LastCommit) { - info.LastCommit = commit.CommittedDate.Time + + for _, commit := range query.Repository.DefaultBranchRef.Target.Commit.History.Nodes { + author := commit.Author.Name + if author == "" { + author = commit.Author.Email + } + info := authorStats[author] + info.Count++ + if commit.CommittedDate.Time.After(info.LastCommit) { + info.LastCommit = commit.CommittedDate.Time + } + authorStats[author] = info } - authorStats[author] = info - } - hasNextPage = query.Repository.DefaultBranchRef.Target.Commit.History.PageInfo.HasNextPage - if hasNextPage { - variables["after"] = githubv4.String(query.Repository.DefaultBranchRef.Target.Commit.History.PageInfo.EndCursor) + hasNextPage = query.Repository.DefaultBranchRef.Target.Commit.History.PageInfo.HasNextPage + if hasNextPage { + variables["after"] = githubv4.String(query.Repository.DefaultBranchRef.Target.Commit.History.PageInfo.EndCursor) + } } - } - return authorStats, nil + return authorStats, nil + } } diff --git a/internal/providers/provider.go b/internal/providers/provider.go index 0684d55..4707bc0 100644 --- a/internal/providers/provider.go +++ b/internal/providers/provider.go @@ -6,7 +6,7 @@ import ( // CommitProvider defines the interface for different VCS providers type CommitProvider interface { - FetchCommits(owner, repo string) (map[string]CommitInfo, error) + FetchCommits(owner, repo string, duration string) (map[string]CommitInfo, error) } type CommitInfo struct { diff --git a/internal/util/url_parser.go b/internal/util/url_parser.go index be61fd0..850fe5d 100644 --- a/internal/util/url_parser.go +++ b/internal/util/url_parser.go @@ -4,13 +4,20 @@ import ( "fmt" "os" "strings" + "time" ) +type Duration struct { + Value int + Unit string // "day", "month", "year" +} + type RepoInfo struct { Owner string Repo string ProviderType string Config map[string]string + Duration *Duration } func ParseRepositoryURL(url string) (*RepoInfo, error) { @@ -61,3 +68,40 @@ func ParseRepositoryURL(url string) (*RepoInfo, error) { return info, nil } + +func ParseDuration(duration string) (*Duration, error) { + if duration == "" { + return nil, nil + } + + var value int + var unit string + _, err := fmt.Sscanf(duration, "%d%s", &value, &unit) + if err != nil { + return nil, fmt.Errorf("invalid duration format. Use: 6m, 1y, 30d") + } + + switch unit { + case "d", "day", "days": + return &Duration{Value: value, Unit: "day"}, nil + case "m", "month", "months": + return &Duration{Value: value, Unit: "month"}, nil + case "y", "year", "years": + return &Duration{Value: value, Unit: "year"}, nil + default: + return nil, fmt.Errorf("invalid duration unit. Use: d(days), m(months), y(years)") + } +} + +func (d *Duration) ToTime() time.Time { + now := time.Now() + switch d.Unit { + case "day": + return now.AddDate(0, 0, -d.Value) + case "month": + return now.AddDate(0, -d.Value, 0) + case "year": + return now.AddDate(-d.Value, 0, 0) + } + return now +} diff --git a/main.go b/main.go index d7d36e2..7e57715 100644 --- a/main.go +++ b/main.go @@ -30,6 +30,7 @@ func getRepositoryURL() (string, error) { func main() { outputFormat := flag.String("output", "", "Output format: console, json, or markdown") + duration := flag.String("duration", "", "Analyze commits for last duration (e.g., 6m, 1y, 30d)") flag.Parse() err := godotenv.Load() @@ -48,6 +49,10 @@ func main() { } } + if *duration != "" { + os.Setenv("COMMIT_HISTORY_DURATION", *duration) + } + url, err := getRepositoryURL() if err != nil { log.Fatal(err) @@ -65,7 +70,7 @@ func main() { } repoAnalyzer := analyzer.NewRepositoryAnalyzer(provider) - stats, err := repoAnalyzer.Analyze(repoInfo.Owner, repoInfo.Repo) + stats, err := repoAnalyzer.Analyze(repoInfo.Owner, repoInfo.Repo, *duration) if err != nil { log.Fatal(err) } diff --git a/test.md b/test.md new file mode 100644 index 0000000..c7f834c --- /dev/null +++ b/test.md @@ -0,0 +1,372 @@ +![Gitness](https://github.com/erdemkosk/gitness/blob/master/logo.png?raw=true) + +# Repository Analysis: expressjs/express + +## 📅 Analysis Period: All Time + +## 🟢 Bus Factor: **4** (critical if < 2, warning if < 4) + +## 🟢 Knowledge Distribution Score: **99.7** (0-100, higher is better) + +## 🔴 Active Contributor Ratio: **1.7%** (contributors with >1% contribution, critical if < 30%, warning if < 50%) + +## 🟢 Recent Contributors: **13** (active in last 3 months, critical if < 2, warning if < 5) + +Total Commits: 6011 + +## Contributors + +| Name | Commits | Percentage | +|------|---------|------------| +| Tj Holowaychuk | 1891 | 31.5% | +| visionmedia | 1285 | 21.4% | +| Douglas Christopher Wilson | 1232 | 20.5% | +| TJ Holowaychuk | 705 | 11.7% | +| Jonathan Ong | 84 | 1.4% | +| Roman Shtylman | 70 | 1.2% | +| Aaron Heckmann | 54 | 0.9% | +| Wes Todd | 39 | 0.6% | +| csausdev | 34 | 0.6% | +| ciaranj | 26 | 0.4% | +| Ulises Gascón | 25 | 0.4% | +| Robert Sköld | 21 | 0.3% | +| Blake Embrey | 17 | 0.3% | +| Jon Church | 17 | 0.3% | +| Jamie Barton | 8 | 0.1% | +| Rand McKinney | 8 | 0.1% | +| Michael Ahlers | 8 | 0.1% | +| Guillermo Rauch | 7 | 0.1% | +| Ulises Gascon | 7 | 0.1% | +| ctcpip | 7 | 0.1% | +| Rakesh Bisht | 7 | 0.1% | +| Sebastian Beltran | 7 | 0.1% | +| Greg Methvin | 6 | 0.1% | +| Mike Tunnicliffe | 6 | 0.1% | +| Hashen | 6 | 0.1% | +| Marco Ippolito | 5 | 0.1% | +| 3imed-jaberi | 5 | 0.1% | +| riadh | 5 | 0.1% | +| Fernando Silveira | 5 | 0.1% | +| Aravind Nair | 5 | 0.1% | +| Owen Luke | 5 | 0.1% | +| Phillip Barta | 4 | 0.1% | +| Jérémy Lal | 4 | 0.1% | +| Yogi | 4 | 0.1% | +| Chris de Almeida | 4 | 0.1% | +| Evan Hahn | 4 | 0.1% | +| Eduardo Sorribas | 4 | 0.1% | +| chainhelen | 4 | 0.1% | +| Min-su Ok | 3 | 0.0% | +| Dav Glass | 3 | 0.0% | +| Fishrock123 | 3 | 0.0% | +| Oliver Salzburg | 3 | 0.0% | +| Carlos Serrano | 3 | 0.0% | +| sakateka | 3 | 0.0% | +| Nadav Ivgi | 3 | 0.0% | +| S M Mahmudul Hasan | 3 | 0.0% | +| Arnaud Benhamdine | 3 | 0.0% | +| Nick Poulden | 3 | 0.0% | +| Wes | 3 | 0.0% | +| Ashley Streb | 2 | 0.0% | +| marco-ippolito | 2 | 0.0% | +| Hrvoje Šimić | 2 | 0.0% | +| Hunter Loftis | 2 | 0.0% | +| Gireesh Punathil | 2 | 0.0% | +| Jake Gordon | 2 | 0.0% | +| Young Jae Sim | 2 | 0.0% | +| Tiago Relvao | 2 | 0.0% | +| Dave Hoover | 2 | 0.0% | +| Mert Can Altin | 2 | 0.0% | +| Randy Merrill | 2 | 0.0% | +| Steve Bartnesky | 2 | 0.0% | +| Ben Atkin | 2 | 0.0% | +| Phat | 2 | 0.0% | +| Pierre Matri | 2 | 0.0% | +| Ben Weaver | 2 | 0.0% | +| Brian McKinney | 2 | 0.0% | +| Nicholas Kinsey | 2 | 0.0% | +| KoyamaSohei | 2 | 0.0% | +| Anna Bocharova | 2 | 0.0% | +| Felix Bünemann | 2 | 0.0% | +| Alberto Leal | 2 | 0.0% | +| Linus Unnebäck | 2 | 0.0% | +| Doug Patti | 2 | 0.0% | +| Kris Kalavantavanich | 2 | 0.0% | +| agchou | 2 | 0.0% | +| Ángel Sanz | 2 | 0.0% | +| Eivind Fjeldstad | 2 | 0.0% | +| 김정환 | 2 | 0.0% | +| Daniel Shaw | 2 | 0.0% | +| Matt Copperwaite | 2 | 0.0% | +| Jon Koops | 2 | 0.0% | +| lemmy | 2 | 0.0% | +| Jon Jenkins | 2 | 0.0% | +| Hussein Mohamed | 2 | 0.0% | +| Sean Soong | 2 | 0.0% | +| Benny Wong | 2 | 0.0% | +| Matheus Azzi | 2 | 0.0% | +| Rodion Abdurakhimov | 2 | 0.0% | +| Eric Ferraiuolo | 2 | 0.0% | +| Max Riveiro | 2 | 0.0% | +| Benjen | 1 | 0.0% | +| Moni | 1 | 0.0% | +| David Wu | 1 | 0.0% | +| Deniz Tetik | 1 | 0.0% | +| Julian Gruber | 1 | 0.0% | +| Kim | 1 | 0.0% | +| Ben Edmunds | 1 | 0.0% | +| Adam Sanderson | 1 | 0.0% | +| Ivan Fraixedes | 1 | 0.0% | +| Ke Zhu | 1 | 0.0% | +| Viktor Kelemen | 1 | 0.0% | +| Thom Seddon | 1 | 0.0% | +| Czarek | 1 | 0.0% | +| Guy Ellis Monster | 1 | 0.0% | +| Chang Wang | 1 | 0.0% | +| Andrea Giammarchi | 1 | 0.0% | +| Mark | 1 | 0.0% | +| Joshua Goldberg | 1 | 0.0% | +| Yang Wang | 1 | 0.0% | +| Sandro Santilli | 1 | 0.0% | +| Josh Buker | 1 | 0.0% | +| Pravdomil | 1 | 0.0% | +| FDrag0n | 1 | 0.0% | +| Andy | 1 | 0.0% | +| Colin Richardson | 1 | 0.0% | +| Patrick Williams | 1 | 0.0% | +| zhangky | 1 | 0.0% | +| Takuma Hanatani | 1 | 0.0% | +| Ignacio Carbajo | 1 | 0.0% | +| Hage Yaapa | 1 | 0.0% | +| Dmitry Bochkarev | 1 | 0.0% | +| David Wood | 1 | 0.0% | +| Yaman Jain | 1 | 0.0% | +| Tom Hosford | 1 | 0.0% | +| Ricardo Bin | 1 | 0.0% | +| Troy Goode | 1 | 0.0% | +| 刘星 | 1 | 0.0% | +| Paul Miller | 1 | 0.0% | +| Alexey Kucherenko | 1 | 0.0% | +| Teng Siong Ong | 1 | 0.0% | +| yasser | 1 | 0.0% | +| Ciro Santilli | 1 | 0.0% | +| yosssi | 1 | 0.0% | +| pikeas | 1 | 0.0% | +| HubCodes | 1 | 0.0% | +| H3RSKO | 1 | 0.0% | +| Bessie Chan | 1 | 0.0% | +| Patrick Pfeiffer | 1 | 0.0% | +| Forbes Lindesay | 1 | 0.0% | +| Alex Kocharin | 1 | 0.0% | +| Mick A. | 1 | 0.0% | +| Alexander Shemetovsky | 1 | 0.0% | +| Oz Michaeli | 1 | 0.0% | +| Kent C. Dodds | 1 | 0.0% | +| void | 1 | 0.0% | +| Horatiu Eugen Vlad | 1 | 0.0% | +| Sebastian Van Sande | 1 | 0.0% | +| Yad Smood | 1 | 0.0% | +| Thomas Cort | 1 | 0.0% | +| 8bitDesigner | 1 | 0.0% | +| Pau Ramon | 1 | 0.0% | +| Szymon Łągiewka | 1 | 0.0% | +| Piper Chester | 1 | 0.0% | +| Rick Yakubowski | 1 | 0.0% | +| Lars-Magnus Skog | 1 | 0.0% | +| Hack Sparrow | 1 | 0.0% | +| Lutger Kunst | 1 | 0.0% | +| Alejandro Estrada | 1 | 0.0% | +| Benjamin Tan | 1 | 0.0% | +| Alex Upadhyay | 1 | 0.0% | +| shuwatto | 1 | 0.0% | +| Alexandru Dragomir | 1 | 0.0% | +| Abderrahmenla | 1 | 0.0% | +| Michael Ficarra | 1 | 0.0% | +| Rich Hodgkins | 1 | 0.0% | +| Muhammad Saqib | 1 | 0.0% | +| Behcet Uyar | 1 | 0.0% | +| Jaime Agudo | 1 | 0.0% | +| colynb | 1 | 0.0% | +| Kevin Jones | 1 | 0.0% | +| Vladimir Grinenko | 1 | 0.0% | +| Ken Sato | 1 | 0.0% | +| Gregory Ritter | 1 | 0.0% | +| Atharva | 1 | 0.0% | +| Andy Hiew | 1 | 0.0% | +| Jim Snodgrass | 1 | 0.0% | +| Abhinav Das | 1 | 0.0% | +| apeltop | 1 | 0.0% | +| Brendan Ashworth | 1 | 0.0% | +| Veselin Todorov | 1 | 0.0% | +| Alex Dixon | 1 | 0.0% | +| Ashwin Purohit | 1 | 0.0% | +| Jonathan Palardy | 1 | 0.0% | +| Andy Fleming | 1 | 0.0% | +| Amir Abu Shareb | 1 | 0.0% | +| Jenkins | 1 | 0.0% | +| Seth Samuel | 1 | 0.0% | +| Eslam Salem | 1 | 0.0% | +| Logan Ripplinger | 1 | 0.0% | +| Yuta Hiroto | 1 | 0.0% | +| Aaron Clover | 1 | 0.0% | +| Max Melentiev | 1 | 0.0% | +| Brian J Brennan | 1 | 0.0% | +| James Herdman | 1 | 0.0% | +| Raymond Feng | 1 | 0.0% | +| lxe | 1 | 0.0% | +| caioagiani | 1 | 0.0% | +| Abdul Rauf | 1 | 0.0% | +| riadhchtara | 1 | 0.0% | +| Hamza | 1 | 0.0% | +| Jeremiah Senkpiel | 1 | 0.0% | +| Deepak Kapoor | 1 | 0.0% | +| muratgu | 1 | 0.0% | +| Jordan Larson | 1 | 0.0% | +| Joe McCann | 1 | 0.0% | +| Deniz | 1 | 0.0% | +| Alexander Marenin | 1 | 0.0% | +| Jake Seip | 1 | 0.0% | +| Diogo Resende | 1 | 0.0% | +| yawnt | 1 | 0.0% | +| Masahiro Hayashi | 1 | 0.0% | +| Josh Dague | 1 | 0.0% | +| Vishvajit Pathak | 1 | 0.0% | +| Aditya Srivastava | 1 | 0.0% | +| Jan Buschtöns | 1 | 0.0% | +| ewoudj | 1 | 0.0% | +| Andy VanWagoner | 1 | 0.0% | +| riddlew | 1 | 0.0% | +| Andreas Kohn | 1 | 0.0% | +| Jianru Lin | 1 | 0.0% | +| silentjohnny | 1 | 0.0% | +| alberto barboza | 1 | 0.0% | +| Nacim Goura | 1 | 0.0% | +| ChALkeR | 1 | 0.0% | +| Vasilyev Dmitry | 1 | 0.0% | +| M Alix | 1 | 0.0% | +| Benjamin Hanes | 1 | 0.0% | +| Steven Anthony | 1 | 0.0% | +| AbdelMonaam Aouini | 1 | 0.0% | +| Íñigo Marquínez Prado | 1 | 0.0% | +| Tobias Speicher | 1 | 0.0% | +| Fei Yao | 1 | 0.0% | +| Fabien Franzen | 1 | 0.0% | +| mgutz | 1 | 0.0% | +| Ritchie Martori | 1 | 0.0% | +| Paul Serby | 1 | 0.0% | +| Marcin Wanago | 1 | 0.0% | +| Tommaso Tofacchi | 1 | 0.0% | +| Sung Kim | 1 | 0.0% | +| Wang | 1 | 0.0% | +| Chris Barth | 1 | 0.0% | +| Daniel Tschinder | 1 | 0.0% | +| ctide | 1 | 0.0% | +| Eric Willis | 1 | 0.0% | +| Vitaly | 1 | 0.0% | +| christof louw | 1 | 0.0% | +| Lawrence Page | 1 | 0.0% | +| Radu Dan | 1 | 0.0% | +| Leonard Martin | 1 | 0.0% | +| Maciej Małecki | 1 | 0.0% | +| Shahan Arshad | 1 | 0.0% | +| Austin Scriver | 1 | 0.0% | +| Aria Stewart | 1 | 0.0% | +| phoenix | 1 | 0.0% | +| Timo Sand | 1 | 0.0% | +| REALSTEVEIG | 1 | 0.0% | +| isaacs | 1 | 0.0% | +| fern4lvarez | 1 | 0.0% | +| Arpad Borsos | 1 | 0.0% | +| vallabh | 1 | 0.0% | +| Ruben Verborgh | 1 | 0.0% | +| Pero Pejovic | 1 | 0.0% | +| Peter Rekdal Sunde | 1 | 0.0% | +| Alexander Belov | 1 | 0.0% | +| Jonathan Zacsh | 1 | 0.0% | +| Carlos Souza | 1 | 0.0% | +| Raz Luvaton | 1 | 0.0% | +| Philippe Lafoucrière | 1 | 0.0% | +| nickjj | 1 | 0.0% | +| Adrian Olaru | 1 | 0.0% | +| Matt Colyer | 1 | 0.0% | +| Hung HOANG | 1 | 0.0% | +| Tony Anisimov | 1 | 0.0% | +| Charles Holbrow | 1 | 0.0% | +| Alex Weeks | 1 | 0.0% | +| huadong zuo | 1 | 0.0% | +| saintelama | 1 | 0.0% | +| Josh Lubawy | 1 | 0.0% | +| Tony Edwards | 1 | 0.0% | +| tstrimple | 1 | 0.0% | +| Andrew Heaney | 1 | 0.0% | +| Sian January | 1 | 0.0% | +| WON JONG YOO | 1 | 0.0% | +| cjihrig | 1 | 0.0% | +| Candid Dauth | 1 | 0.0% | +| Hamir Mahal | 1 | 0.0% | +| James George | 1 | 0.0% | +| Dan Neumann | 1 | 0.0% | +| Greg Ritter | 1 | 0.0% | +| drewm | 1 | 0.0% | +| Ryan Seys | 1 | 0.0% | +| Nick Heiner | 1 | 0.0% | +| Manuel Baesler | 1 | 0.0% | +| Aalaap Ghag | 1 | 0.0% | +| Thomas Strauß | 1 | 0.0% | +| Jonathan Dumaine | 1 | 0.0% | +| Christoph Walcher | 1 | 0.0% | +| Phillip9587 | 1 | 0.0% | +| Steven | 1 | 0.0% | +| Elvin Yung | 1 | 0.0% | +| Pavel Brylov | 1 | 0.0% | +| Alexander Pirsig | 1 | 0.0% | +| Yash Kumar | 1 | 0.0% | +| Ilya Guterman | 1 | 0.0% | +| Seth Krasnianski | 1 | 0.0% | +| John Gosset | 1 | 0.0% | +| Bent Cardan | 1 | 0.0% | +| Isaac Z. Schlueter | 1 | 0.0% | +| Tito D. Kesumo Siregar | 1 | 0.0% | +| Ivan Derevianko | 1 | 0.0% | +| Greg Guthe | 1 | 0.0% | +| Daniel Walasek | 1 | 0.0% | +| Igor Mozharovsky | 1 | 0.0% | +| Nick Mandel | 1 | 0.0% | +| chirag04 | 1 | 0.0% | +| Louis | 1 | 0.0% | +| Joshua Caron | 1 | 0.0% | +| Julien Gilli | 1 | 0.0% | +| Ben Lewis | 1 | 0.0% | +| Roman Zubenko | 1 | 0.0% | +| Kevin Shay | 1 | 0.0% | +| Sean Linsley | 1 | 0.0% | +| jade-bot | 1 | 0.0% | +| Bhavya Dhiman | 1 | 0.0% | +| Juan José Arboleda | 1 | 0.0% | +| Zachary Lester | 1 | 0.0% | +| asaf david | 1 | 0.0% | +| Florian Brandt | 1 | 0.0% | +| Haoliang Gao | 1 | 0.0% | +| Caridy Patino | 1 | 0.0% | +| Darren Torpey | 1 | 0.0% | +| yanokenken | 1 | 0.0% | +| Alvin Smith | 1 | 0.0% | +| Dmitriy | 1 | 0.0% | +| Josemar Magalhaes | 1 | 0.0% | +| Justin Lilly | 1 | 0.0% | +| Esco Obong | 1 | 0.0% | +| Raynos | 1 | 0.0% | +| Andrii Kostenko | 1 | 0.0% | +| Chris Andrejewski | 1 | 0.0% | +| SeongHeon Kim | 1 | 0.0% | +| huaoguo | 1 | 0.0% | +| Mikeal Rogers | 1 | 0.0% | +| Pavel Lang | 1 | 0.0% | +| joshlangner | 1 | 0.0% | +| lihan | 1 | 0.0% | +| Thorsten Lorenz | 1 | 0.0% | +| Dmitry Kondar | 1 | 0.0% | +| Ghouse Mohamed | 1 | 0.0% | +| Amit Zur | 1 | 0.0% | +| Kunal Pathak | 1 | 0.0% | +