diff --git a/client/client.go b/client/client.go index 50d0283..7d2d3f4 100644 --- a/client/client.go +++ b/client/client.go @@ -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) @@ -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 @@ -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 @@ -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) diff --git a/client/client_test.go b/client/client_test.go index 43efa2b..43dd18d 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -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 @@ -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} diff --git a/format/human_formatter.go b/format/human_formatter.go index 50d78a9..f3ffb6b 100644 --- a/format/human_formatter.go +++ b/format/human_formatter.go @@ -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) diff --git a/format/human_formatter_test.go b/format/human_formatter_test.go index f662ab5..3c4ee94 100644 --- a/format/human_formatter_test.go +++ b/format/human_formatter_test.go @@ -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()) } diff --git a/format/humanize.go b/format/humanize.go index e315385..67bd228 100644 --- a/format/humanize.go +++ b/format/humanize.go @@ -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) } diff --git a/format/humanize_test.go b/format/humanize_test.go index cf5ef81..0c1def8 100644 --- a/format/humanize_test.go +++ b/format/humanize_test.go @@ -17,6 +17,9 @@ func TestHumanize(t *testing.T) { {name: "s