Skip to content
Open
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
67 changes: 65 additions & 2 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import (
"github.com/cli/go-gh/pkg/api"
)

const userTypeOrganization = "Organization"
const userTypeUser = "User"

// New creates a new Client instance, initialized with a GH RESTClient
func New() Client {
rest, err := gh.RESTClient(nil)
Expand Down Expand Up @@ -55,6 +58,15 @@ func (e UnexpectedHostError) Error() string {
return "Unexpected host: " + string(e)
}

// BillingUnavailableError is returned when the billing API cannot be accessed due to missing
// permissions. The value is the HTTP status code returned by the API.
type BillingUnavailableError int

// Error returns a formatted error message for BillingUnavailableError
func (e BillingUnavailableError) Error() string {
return fmt.Sprintf("Billing API unavailable (HTTP %d): a token with billing permissions is required to retrieve usage data", int(e))
}

// GetWorkflows returns a slice of Workflow instances, one for each workflow in the repository
func (c *Client) GetWorkflows(repository Repository) ([]Workflow, error) {
var page uint8 = 1
Expand Down Expand Up @@ -121,6 +133,57 @@ func (u *Usage) TotalMs() uint {
return total
}

// BillingUsageItem represents a single line item in a billing usage report.
// Quantity is in the unit specified by UnitType (minutes for Actions).
// RepositoryName is in "owner/repo" format.
type BillingUsageItem struct {
Date string `json:"date"`
Product string `json:"product"`
SKU string `json:"sku"`
Quantity float64 `json:"quantity"`
UnitType string `json:"unitType"`
PricePerUnit float64 `json:"pricePerUnit"`
GrossAmount float64 `json:"grossAmount"`
DiscountAmount float64 `json:"discountAmount"`
NetAmount float64 `json:"netAmount"`
OrganizationName string `json:"organizationName"`
RepositoryName string `json:"repositoryName"`
}

// BillingUsageReport is the response from the billing usage API
type BillingUsageReport struct {
UsageItems []BillingUsageItem `json:"usageItems"`
}

// GetActionsUsage returns Actions billing usage for a user or organization for the current billing period.
// Returns nil when the user or organization is not on the enhanced billing platform (404).
func (c *Client) GetActionsUsage(user *User) (*BillingUsageReport, error) {
var path string
switch user.Type {
case userTypeOrganization:
path = fmt.Sprintf("organizations/%s/settings/billing/usage", user.Login)
case userTypeUser:
path = fmt.Sprintf("users/%s/settings/billing/usage", user.Login)
default:
return nil, UnexpectedUserTypeError(user.Type)
}
response := BillingUsageReport{}
err := c.Rest.Get(path, &response)
if err != nil {
var httpError api.HTTPError
if errors.As(err, &httpError) {
switch httpError.StatusCode {
case http.StatusNotFound:
return nil, nil
case http.StatusForbidden:
return nil, BillingUnavailableError(httpError.StatusCode)
}
}
return nil, fmt.Errorf("could not get actions usage: %w", err)
}
return &response, nil
}

// Repository represents a GitHub Repository
type Repository struct {
Owner *User
Expand Down Expand Up @@ -207,9 +270,9 @@ func (c *Client) GetAllRepositories(user *User) ([]*Repository, error) {

func (c *Client) getAllRepositoriesPath(user *User, page uint8) (string, error) {
switch user.Type {
case "Organization":
case userTypeOrganization:
return fmt.Sprintf("orgs/%s/repos?page=%d", user.Login, page), nil
case "User":
case userTypeUser:
return fmt.Sprintf("users/%s/repos?page=%d", user.Login, page), nil
default:
return "", UnexpectedUserTypeError(user.Type)
Expand Down
66 changes: 65 additions & 1 deletion client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ func TestClient_GetUser(t *testing.T) {
u := args.Get(1).(*User)
u.ID = 103469606
u.Login = "codiform"
u.Type = "Organization"
u.Type = userTypeOrganization
})

// When
Expand Down Expand Up @@ -219,6 +219,70 @@ func TestClient_GetAllRepositories(t *testing.T) {
}
}

func TestClient_GetActionsUsage_User(t *testing.T) {
// Given
rest, client := getTestClient()
owner := &User{ID: 49935, Login: "geoffreywiseman", Type: userTypeUser}
rest.On("Get", "users/geoffreywiseman/settings/billing/usage", mock.Anything).
Return(nil).
Run(func(args mock.Arguments) {
report := args.Get(1).(*BillingUsageReport)
report.UsageItems = []BillingUsageItem{
{Date: "2024-01-01", Product: "Actions", SKU: "Actions Linux", Quantity: 100, UnitType: "minutes", RepositoryName: "geoffreywiseman/gh-actuse"},
{Date: "2024-01-02", Product: "Actions", SKU: "Actions Linux", Quantity: 50, UnitType: "minutes", RepositoryName: "geoffreywiseman/gh-actuse"},
}
})

// When
report, err := client.GetActionsUsage(owner)

// Then
require.NoError(t, err)
require.NotNil(t, report)
assert.Len(t, report.UsageItems, 2)
assert.Equal(t, "Actions Linux", report.UsageItems[0].SKU)
assert.InDelta(t, float64(100), report.UsageItems[0].Quantity, 0.001)
}

func TestClient_GetActionsUsage_Organization(t *testing.T) {
// Given
rest, client := getTestClient()
owner := &User{ID: 103469606, Login: "codiform", Type: userTypeOrganization}
rest.On("Get", "organizations/codiform/settings/billing/usage", mock.Anything).
Return(nil).
Run(func(args mock.Arguments) {
report := args.Get(1).(*BillingUsageReport)
report.UsageItems = []BillingUsageItem{
{Date: "2024-01-01", Product: "Actions", SKU: "Actions Linux", Quantity: 200, UnitType: "minutes", RepositoryName: "codiform/gh-actions-usage"},
{Date: "2024-01-01", Product: "Actions", SKU: "Actions macOS", Quantity: 30, UnitType: "minutes", RepositoryName: "codiform/gh-actions-usage"},
}
})

// When
report, err := client.GetActionsUsage(owner)

// Then
require.NoError(t, err)
require.NotNil(t, report)
assert.Len(t, report.UsageItems, 2)
assert.Equal(t, "codiform/gh-actions-usage", report.UsageItems[0].RepositoryName)
}

func TestClient_GetActionsUsage_UnexpectedType(t *testing.T) {
// Given
_, client := getTestClient()
owner := &User{ID: 1, Login: "bot", Type: "Bot"}

// When
report, err := client.GetActionsUsage(owner)

// Then
assert.Nil(t, report)
require.Error(t, err)
var unexpectedType UnexpectedUserTypeError
assert.ErrorAs(t, err, &unexpectedType)
}

func getTestClient() (*mocks.RestMock, Client) {
rest := new(mocks.RestMock)
return rest, Client{Rest: rest}
Expand Down
6 changes: 5 additions & 1 deletion format/human_formatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ func (hf humanFormatter) PrintUsage(usage client.RepoUsage) {
} else {
_, _ = fmt.Fprintf(hf.w, "%s (%d workflows; %s%s):\n", repo.Repo.FullName, len(repo.Workflows), Humanize(repo.Total), visibility)
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))
if workflow.Workflow.Path == "" {
_, _ = fmt.Fprintf(hf.w, "- %s (%s)\n", workflow.Workflow.Name, Humanize(workflow.Usage))
} else {
_, _ = 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)
Expand Down
36 changes: 31 additions & 5 deletions format/human_formatter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,18 +98,44 @@ func TestHumanFormatter_Totals(t *testing.T) {
formatter.PrintUsage(ru)

// Then
assert.Equal(t, `codiform/gh-actions-usage (2 workflows; 2s 0ms):
assert.Equal(t, `codiform/gh-actions-usage (2 workflows; 2s):
- 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)
codiform/terraform-tools (1 workflows; 1s):
- CI (.github/workflows/ci.yml, active, 1s)

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

Totals:
- codiform (2 repositories; 3 workflows; 3s 0ms)
- codiform (2 repositories; 3 workflows; 3s)
- geoffreywiseman (1 repositories; 0 workflows; 0ms)
- all repositories (3 repositories; 3 workflows; 3s 0ms)
- all repositories (3 repositories; 3 workflows; 3s)
`, output.String())
}

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

// SKU-based workflows have no Path or State (billing API data)
linux := client.Workflow{Name: "Actions Linux"}
macos := client.Workflow{Name: "Actions macOS"}
wfu := client.WorkflowUsage{
linux: 100 * 60000, // 100 minutes in ms
macos: 30 * 60000, // 30 minutes in ms
}
r := client.Repository{FullName: "codiform/gh-actions-usage", Private: true}
ru := client.RepoUsage{&r: wfu}

// When
formatter.PrintUsage(ru)

// Then
assert.Equal(t, `codiform/gh-actions-usage (2 workflows; 2h 10m):
- Actions Linux (1h 40m)
- Actions macOS (30m)

`, output.String())
}
32 changes: 23 additions & 9 deletions format/humanize.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,28 @@ const msInH = msInM * 60

// Humanize returns unit milliseconds in a simple human-readable form
func Humanize(ms uint) string {
switch {
case ms < msInS:
return fmt.Sprintf("%dms", ms)
case ms < msInM:
return fmt.Sprintf("%ds %dms", ms/msInS, ms%msInS)
case ms < msInH:
return fmt.Sprintf("%dm %ds", ms/msInM, (ms%msInM)/msInS)
default:
return fmt.Sprintf("%dh %dm", ms/msInH, (ms%msInH)/msInM)
hours := ms / msInH
minutes := (ms % msInH) / msInM
seconds := (ms % msInM) / msInS
millis := ms % msInS

if hours > 0 {
if minutes == 0 {
return fmt.Sprintf("%dh", hours)
}
return fmt.Sprintf("%dh %dm", hours, minutes)
}
if minutes > 0 {
if seconds == 0 {
return fmt.Sprintf("%dm", minutes)
}
return fmt.Sprintf("%dm %ds", minutes, seconds)
}
if seconds > 0 {
if millis == 0 {
return fmt.Sprintf("%ds", seconds)
}
return fmt.Sprintf("%ds %dms", seconds, millis)
}
return fmt.Sprintf("%dms", millis)
}
3 changes: 3 additions & 0 deletions format/humanize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ func TestHumanize(t *testing.T) {
{name: "s<test<m", ms: 12_345, humanized: "12s 345ms"},
{name: "m<test<h", ms: 754_567, humanized: "12m 34s"},
{name: "h<test", ms: 45_240_000, humanized: "12h 34m"},
{name: "exact_s", ms: 1_000, humanized: "1s"},
{name: "exact_m", ms: 60_000, humanized: "1m"},
{name: "exact_h", ms: 3_600_000, humanized: "1h"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
Expand Down
Loading