Skip to content
Merged
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,22 @@ codiform/gh-actions-usage (2 workflows; 1h 1s):
- release (.github/workflows/release.yml, active, 39s 980ms)
```

Display the usage for multiple specified repositories:
Display the usage for multiple specified repositories. When more than one repository is shown, the output also includes totals by owner and for all targets:
```
gh-actions-usage on  main [!+] via 🐹 v1.19.4
❯ gh actions-usage geoffreywiseman/gh-actuse codiform/gh-actions-usage
GitHub Actions Usage

geoffreywiseman/gh-actuse (0 workflows)

codiform/gh-actions-usage (2 workflows; 0ms):
- CI (.github/workflows/ci.yml, active, 0ms)
- release (.github/workflows/release.yml, active, 0ms)

geoffreywiseman/gh-actuse (0 workflows)

Totals:
- codiform (1 repositories; 2 workflows; 0ms)
- geoffreywiseman (1 repositories; 0 workflows; 0ms)
- all repositories (2 repositories; 2 workflows; 0ms)
```

Display the usage for all repos of an organization:
Expand All @@ -71,6 +76,10 @@ GitHub Actions Usage
kkruszewska/data_polishers_titanic (0 workflows)

kkruszewska/hello-world (0 workflows)

Totals:
- kkruszewska (2 repositories; 0 workflows; 0ms)
- all repositories (2 repositories; 0 workflows; 0ms)
```

Display the usage for a mix of repos, organizations and users:
Expand All @@ -85,6 +94,12 @@ codiform/gh-actions-usage (2 workflows; 0ms):
geoffreywiseman/gh-actuse (0 workflows)

misaha/curly-octo-tribble (0 workflows)

Totals:
- codiform (1 repositories; 2 workflows; 0ms)
- geoffreywiseman (1 repositories; 0 workflows; 0ms)
- misaha (1 repositories; 0 workflows; 0ms)
- all repositories (3 repositories; 2 workflows; 0ms)
```

Display the usage for a mix of repos using a tab-separated value format (TSV):
Expand Down
26 changes: 26 additions & 0 deletions format/formatter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package format

import "github.com/geoffreywiseman/gh-actions-usage/client"

func sampleMultipleRepositoriesUsage() client.RepoUsage {
codiform := &client.User{Login: "codiform"}
geoffreywiseman := &client.User{Login: "geoffreywiseman"}

ci := client.Workflow{Name: "CI", Path: ".github/workflows/ci.yml", State: "active"}
release := client.Workflow{Name: "Release", Path: ".github/workflows/release.yml", State: "active"}

firstRepo := &client.Repository{Owner: codiform, FullName: "codiform/gh-actions-usage"}
secondRepo := &client.Repository{Owner: codiform, FullName: "codiform/terraform-tools"}
thirdRepo := &client.Repository{Owner: geoffreywiseman, FullName: "geoffreywiseman/gh-actuse"}

return client.RepoUsage{
firstRepo: {
release: 1500,
ci: 500,
},
secondRepo: {
ci: 1000,
},
thirdRepo: {},
}
}
29 changes: 16 additions & 13 deletions format/human_formatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,25 @@ type humanFormatter struct {
}

func (hf humanFormatter) PrintUsage(usage client.RepoUsage) {
for repo, flowUsage := range usage {
var lines = make([]string, 0, len(flowUsage))
var repoTotal uint
for flow, usage := range flowUsage {
repoTotal += usage
line := fmt.Sprintf("- %s (%s, %s, %s)", flow.Name, flow.Path, flow.State, Humanize(usage))
lines = append(lines, line)
}
if len(lines) == 0 {
_, _ = fmt.Fprintf(hf.w, "%s (0 workflows; 0ms)\n", repo.FullName)
summary := summarizeUsage(usage)
for _, repo := range summary.Repos {
if len(repo.Workflows) == 0 {
_, _ = fmt.Fprintf(hf.w, "%s (0 workflows; 0ms)\n", repo.Repo.FullName)
} else {
_, _ = fmt.Fprintf(hf.w, "%s (%d workflows; %s):\n", repo.FullName, len(usage[repo]), Humanize(repoTotal))
for _, line := range lines {
_, _ = fmt.Fprintln(hf.w, line)
_, _ = fmt.Fprintf(hf.w, "%s (%d workflows; %s):\n", repo.Repo.FullName, len(repo.Workflows), Humanize(repo.Total))
for _, workflow := range repo.Workflows {
_, _ = fmt.Fprintf(hf.w, "- %s (%s, %s, %s)\n", workflow.Workflow.Name, workflow.Workflow.Path, workflow.Workflow.State, Humanize(workflow.Usage))
}
}
_, _ = fmt.Fprintln(hf.w)
}
if summary.RepoCount <= 1 {
return
}

_, _ = fmt.Fprintln(hf.w, "Totals:")
for _, owner := range summary.Owners {
_, _ = fmt.Fprintf(hf.w, "- %s (%d repositories; %d workflows; %s)\n", owner.Owner, owner.RepoCount, owner.WorkflowCount, Humanize(owner.Total))
}
_, _ = fmt.Fprintf(hf.w, "- all repositories (%d repositories; %d workflows; %s)\n", summary.RepoCount, summary.WorkflowCount, Humanize(summary.Total))
}
35 changes: 33 additions & 2 deletions format/human_formatter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ func TestHumanFormatter(t *testing.T) {
formatter.PrintUsage(ru)

// Then
assert.Equal(t, "codiform/gh-actions-usage (1 workflows; 50ms):\n- CI (.github/workflows/ci.yml, active, 50ms)\n\n", output.String())
assert.Equal(t, `codiform/gh-actions-usage (1 workflows; 50ms):
- CI (.github/workflows/ci.yml, active, 50ms)

`, output.String())
}

func TestHumanFormatter_Empty(t *testing.T) {
Expand All @@ -40,5 +43,33 @@ func TestHumanFormatter_Empty(t *testing.T) {
formatter.PrintUsage(ru)

// Then
assert.Equal(t, "geoffreywiseman/Moo (0 workflows; 0ms)\n\n", output.String())
assert.Equal(t, `geoffreywiseman/Moo (0 workflows; 0ms)

`, output.String())
}

func TestHumanFormatter_Totals(t *testing.T) {
// Given
var output bytes.Buffer
formatter := humanFormatter{&output}
ru := sampleMultipleRepositoriesUsage()

// When
formatter.PrintUsage(ru)

// Then
assert.Equal(t, `codiform/gh-actions-usage (2 workflows; 2s 0ms):
- CI (.github/workflows/ci.yml, active, 500ms)
- Release (.github/workflows/release.yml, active, 1s 500ms)

codiform/terraform-tools (1 workflows; 1s 0ms):
- CI (.github/workflows/ci.yml, active, 1s 0ms)

geoffreywiseman/gh-actuse (0 workflows; 0ms)

Totals:
- codiform (2 repositories; 3 workflows; 3s 0ms)
- geoffreywiseman (1 repositories; 0 workflows; 0ms)
- all repositories (3 repositories; 3 workflows; 3s 0ms)
`, output.String())
}
31 changes: 26 additions & 5 deletions format/tsv_formatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package format
import (
"fmt"
"io"
"sort"

"github.com/geoffreywiseman/gh-actions-usage/client"
)
Expand All @@ -13,13 +14,33 @@ type tsvFormatter struct {

func (tf tsvFormatter) PrintUsage(usage client.RepoUsage) {
_, _ = fmt.Fprintf(tf.w, "%s\t%s\t%s\n", "Repo", "Workflow", "Milliseconds")
for repo, flowUsage := range usage {
if len(flowUsage) == 0 {
_, _ = fmt.Fprintf(tf.w, "%s\tn/a\t0\n", repo.FullName)
repos := sortedRepositories(usage)
for _, repo := range repos {
workflows := sortedWorkflowUsage(usage[repo])
if len(workflows) == 0 {
_, _ = fmt.Fprintf(tf.w, "%s\tn/a\t0\n", repoFullName(repo))
} else {
for workflow, usage := range flowUsage {
_, _ = fmt.Fprintf(tf.w, "%s\t%s\t%d\n", repo.FullName, workflow.Path, usage)
for _, workflow := range workflows {
_, _ = fmt.Fprintf(tf.w, "%s\t%s\t%d\n", repoFullName(repo), workflow.Workflow.Path, workflow.Usage)
}
}
}
}

func sortedRepositories(usage client.RepoUsage) []*client.Repository {
repos := make([]*client.Repository, 0, len(usage))
for repo := range usage {
repos = append(repos, repo)
}
sort.Slice(repos, func(i, j int) bool {
return repoFullName(repos[i]) < repoFullName(repos[j])
})
return repos
}

func repoFullName(repo *client.Repository) string {
if repo == nil {
return ""
}
return repo.FullName
}
26 changes: 24 additions & 2 deletions format/tsv_formatter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ func TestTsvFormatter(t *testing.T) {
formatter.PrintUsage(ru)

// Then
assert.Equal(t, "Repo\tWorkflow\tMilliseconds\ncodiform/gh-actions-usage\t.github/workflows/DevSecOps.yaml\t2500\n", output.String())
assert.Equal(t, `Repo Workflow Milliseconds
codiform/gh-actions-usage .github/workflows/DevSecOps.yaml 2500
`, output.String())
}

func TestTsvFormatter_Empty(t *testing.T) {
Expand All @@ -41,5 +43,25 @@ func TestTsvFormatter_Empty(t *testing.T) {
formatter.PrintUsage(ru)

// Then
assert.Equal(t, "Repo\tWorkflow\tMilliseconds\nkim0/salt-states\tn/a\t0\n", output.String())
assert.Equal(t, `Repo Workflow Milliseconds
kim0/salt-states n/a 0
`, output.String())
}

func TestTsvFormatter_MultipleRepositories(t *testing.T) {
// Given
var output bytes.Buffer
formatter := tsvFormatter{&output}
ru := sampleMultipleRepositoriesUsage()

// When
formatter.PrintUsage(ru)

// Then
assert.Equal(t, `Repo Workflow Milliseconds
codiform/gh-actions-usage .github/workflows/ci.yml 500
codiform/gh-actions-usage .github/workflows/release.yml 1500
codiform/terraform-tools .github/workflows/ci.yml 1000
geoffreywiseman/gh-actuse n/a 0
`, output.String())
}
127 changes: 127 additions & 0 deletions format/usage_summary.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package format

import (
"sort"
"strings"

"github.com/geoffreywiseman/gh-actions-usage/client"
)

type workflowSummary struct {
Workflow client.Workflow
Usage uint
}

type repoSummary struct {
Repo *client.Repository
Owner string
Workflows []workflowSummary
Total uint
}

type ownerSummary struct {
Owner string
RepoCount int
WorkflowCount int
Total uint
}

type usageSummary struct {
Repos []repoSummary
Owners []ownerSummary
RepoCount int
WorkflowCount int
Total uint
}

// summarizeUsage builds owner and total rollups for human-readable output.
// Collection intentionally stays as raw RepoUsage so each formatter can choose
// how much reorganization it needs without coupling API collection to
// presentation-specific summary rules.
func summarizeUsage(usage client.RepoUsage) usageSummary {
repos := make([]repoSummary, 0, len(usage))
owners := make(map[string]*ownerSummary)

for repo, flowUsage := range usage {
workflows := sortedWorkflowUsage(flowUsage)
var repoTotal uint
for _, workflow := range workflows {
repoTotal += workflow.Usage
}

owner := ownerName(repo)
repos = append(repos, repoSummary{
Repo: repo,
Owner: owner,
Workflows: workflows,
Total: repoTotal,
})

summary := owners[owner]
if summary == nil {
summary = &ownerSummary{Owner: owner}
owners[owner] = summary
}
summary.RepoCount++
summary.WorkflowCount += len(workflows)
summary.Total += repoTotal
}

sort.Slice(repos, func(i, j int) bool {
return repos[i].Repo.FullName < repos[j].Repo.FullName
})

ownerTotals := make([]ownerSummary, 0, len(owners))
var workflowCount int
var total uint
for _, owner := range owners {
ownerTotals = append(ownerTotals, *owner)
workflowCount += owner.WorkflowCount
total += owner.Total
}
sort.Slice(ownerTotals, func(i, j int) bool {
return ownerTotals[i].Owner < ownerTotals[j].Owner
})

return usageSummary{
Repos: repos,
Owners: ownerTotals,
RepoCount: len(repos),
WorkflowCount: workflowCount,
Total: total,
}
}

func sortedWorkflowUsage(flowUsage client.WorkflowUsage) []workflowSummary {
workflows := make([]workflowSummary, 0, len(flowUsage))
for workflow, workflowUsage := range flowUsage {
workflows = append(workflows, workflowSummary{
Workflow: workflow,
Usage: workflowUsage,
})
}
sort.Slice(workflows, func(i, j int) bool {
if workflows[i].Workflow.Path != workflows[j].Workflow.Path {
return workflows[i].Workflow.Path < workflows[j].Workflow.Path
}
if workflows[i].Workflow.Name != workflows[j].Workflow.Name {
return workflows[i].Workflow.Name < workflows[j].Workflow.Name
}
return workflows[i].Workflow.ID < workflows[j].Workflow.ID
})
return workflows
}

func ownerName(repo *client.Repository) string {
if repo != nil && repo.Owner != nil && repo.Owner.Login != "" {
return repo.Owner.Login
}
if repo != nil {
owner, _, found := strings.Cut(repo.FullName, "/")
if found {
return owner
}
return repo.FullName
}
return ""
}