Skip to content
Merged
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
6 changes: 3 additions & 3 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

## Project Overview

`gh-actions-usage` is a [GitHub CLI](https://cli.github.com/) extension written in Go that measures the usage of GitHub Actions workflows in the current billing period (the calendar month, UTC). Usage is computed from job durations via the check runs API, so it works for any visible repository without billing permissions. (Until 2026 it read billable minutes from the per-workflow timing endpoint, which GitHub closed down with its new billing platform; the replacement billing usage report has no per-workflow breakdown.) It is installed and run as `gh actions-usage`.
`gh-actions-usage` is a [GitHub CLI](https://cli.github.com/) extension written in Go that measures the usage of GitHub Actions workflows in the selected billing period (a calendar month in UTC; the current month by default, or a past month via `--month`). Usage is computed from job durations via the check runs API, so it works for any visible repository without billing permissions. (Until 2026 it read billable minutes from the per-workflow timing endpoint, which GitHub closed down with its new billing platform; the replacement billing usage report has no per-workflow breakdown.) It is installed and run as `gh actions-usage`.

## Architecture

- **`main.go`** — Entry point; parses CLI flags (`--output`, `--skip`, `--verbose`), resolves targets to repositories, runs the collector, and prints results or errors.
- **`main.go`** — Entry point; parses CLI flags (`--month`, `--output`, `--skip`, `--verbose`), resolves targets to repositories, runs the collector, and prints results or errors.
- **`client/`** — GitHub API client wrapping `github.com/cli/go-gh/v2`. `client.go` provides `GetCurrentRepository`, `GetRepository`, `GetUser`, `GetAllRepositories`, and `GetWorkflows`; `actions.go` provides `GetWorkflowRuns` (splits windows that exceed GitHub's 1,000-result cap by day), `GetCheckRuns`, and `GetRateLimit`; `transport.go` is an `http.RoundTripper` that paces requests and retries rate-limited ones.
- **`usage/`** — `Collector` computes `client.RepoUsage` for a period: seeds every workflow at zero, lists runs, joins each commit's check runs to workflows by check suite id, and sums completed non-skipped job durations. It preflights the rate limit before fetching check runs and returns `RateLimitError` if the budget is short.
- **`format/`** — Output formatters: `human` (default, readable) and `tsv` (machine-readable). `formatters.go` registers formatters; `usage_summary.go` computes owner/total rollups shared by both formatters.
Expand Down Expand Up @@ -49,4 +49,4 @@ Or use the `justfile` targets: `just lint`, `just test`, `just build`.
- New output formats should implement the `format.Formatter` interface and register via `format.GetFormatter`.
- `format/usage_summary.go` (`summarizeUsage`) provides owner-level and all-repos rollups for formatters that need them.
- The `--skip` flag omits repositories with no workflows from output.
- The period is always the current calendar month in UTC (`usage.CurrentPeriod`); the collector takes explicit `From`/`To` bounds so a period flag could be added without changing it.
- The period is a calendar month in UTC (`usage.Period`): the current month up to now by default, or a past month in full via `--month YYYY-MM`, which `main.go` validates (`parseMonth`) before computing the bounds. The collector takes explicit `From`/`To` bounds and knows nothing about months.
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
![Demo](doc/demo.gif)

# GH Actions Usage
GitHub CLI extension for measuring the usage of GitHub Actions workflows in the *current billing period*, which on
GitHub's billing platform is the calendar month in UTC.
GitHub CLI extension for measuring the usage of GitHub Actions workflows in the *selected billing period*, which on
GitHub's billing platform is a calendar month in UTC. The current month is reported by default; `--month` selects a
past one.
Comment thread
geoffreywiseman marked this conversation as resolved.

Usage is computed from the durations of the jobs that ran in the period, so it works for any repository you can
see, including public repositories and other people's repositories, without any billing permissions. Because it
Expand Down Expand Up @@ -124,6 +125,16 @@ Totals:
- all repositories (3 repositories; 2 workflows; 0ms)
```

Display the usage for a completed month instead of the current one:
```shell
❯ gh actions-usage --month=2026-08 codiform/gh-actions-usage
GitHub Actions Usage

codiform/gh-actions-usage (2 workflows; 4h 5m):
- CI (.github/workflows/ci.yml, active, 4h 3m)
- release (.github/workflows/release.yml, active, 2m 348ms)
Comment thread
geoffreywiseman marked this conversation as resolved.
```

Display the usage for a mix of repos using a tab-separated value format (TSV):

```shell
Expand All @@ -150,6 +161,8 @@ kim0/terraform-switcher .github/workflows/release.yml 1239

## Flags

- `--month=YYYY-MM` selects the billing period to report, a calendar month in UTC; defaults to the current month. The
current month is reported up to now, and a past month in full. Future months are rejected.
- `--output=human|tsv` selects the output format; `tsv` is machine-readable.
- `--skip` omits repositories that have no workflows.
- `--verbose` prints full error details instead of the short message.
Expand Down
61 changes: 52 additions & 9 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,15 @@ import (

var gh client.Client

// monthLayout is the format of the --month flag, a year and month such as 2026-09.
const monthLayout = "2006-01"

type config struct {
format format.Formatter
output string
month string
from time.Time
to time.Time
skip bool
verbose bool
w io.Writer
Expand All @@ -44,15 +50,28 @@ func (e UnknownUserError) Error() string {
return "Unknown user: " + string(e)
}

// InvalidMonthError is an error condition where the --month flag does not name a current or past month
type InvalidMonthError struct {
Value string
Reason string
}

// Error returns a formatted error message for InvalidMonthError
func (e InvalidMonthError) Error() string {
return fmt.Sprintf("Invalid month %q: %s", e.Value, e.Reason)
}

func main() {
fmt.Printf("GitHub Actions Usage (%s)\n\n", getVersion())

gh = client.New()

now := time.Now()
cfg := &config{w: os.Stdout}
flag.BoolVar(&cfg.skip, "skip", false, "Skips displaying repositories with no workflows")
flag.BoolVar(&cfg.verbose, "verbose", false, "Print verbose output including additional error details")
flag.StringVar(&cfg.output, "output", "human", "Output format: human or TSV (machine readable)")
flag.StringVar(&cfg.month, "month", now.UTC().Format(monthLayout), "Billing period to report, as YYYY-MM")
flag.Parse()

var err error
Expand All @@ -63,6 +82,14 @@ func main() {
return
}

month, err := parseMonth(cfg.month, now)
if err != nil {
printError(*cfg, "Invalid option", err)
printHelp()
return
}
cfg.from, cfg.to = usage.Period(month, now)

if len(flag.Args()) < 1 {
tryDisplayCurrentRepo(*cfg)
} else {
Expand Down Expand Up @@ -99,7 +126,7 @@ func tryDisplayCurrentRepo(cfg config) {
printHelp()
return
}
repoFlowUsage, err := collectUsage([]*client.Repository{repo})
repoFlowUsage, err := collectUsage(cfg, []*client.Repository{repo})
if err != nil {
printError(cfg, "Error collecting usage", err)
return
Expand All @@ -118,7 +145,7 @@ func tryDisplayAllSpecified(cfg config, targets []string) {
for _, list := range repos {
targetsList = append(targetsList, list...)
}
repoFlowUsage, err := collectUsage(targetsList)
repoFlowUsage, err := collectUsage(cfg, targetsList)
if err != nil {
printError(cfg, "Error collecting usage", err)
return
Expand All @@ -133,13 +160,25 @@ func tryDisplayAllSpecified(cfg config, targets []string) {
cfg.format.PrintUsage(repoFlowUsage)
}

// collectUsage computes the usage of each repository's workflows for the current billing period.
func collectUsage(repos []*client.Repository) (client.RepoUsage, error) {
from, to := usage.CurrentPeriod(time.Now())
// parseMonth returns the first instant, in UTC, of the month named by value as YYYY-MM. Months that have not
// started yet as of now are rejected, since there can be no usage to report for them.
func parseMonth(value string, now time.Time) (time.Time, error) {
month, err := time.ParseInLocation(monthLayout, value, time.UTC)
if err != nil {
return time.Time{}, InvalidMonthError{Value: value, Reason: "expected a year and month such as 2026-09"}
}
if month.After(now) {
return time.Time{}, InvalidMonthError{Value: value, Reason: "the month has not started yet"}
}
return month, nil
}

// collectUsage computes the usage of each repository's workflows for the configured billing period.
func collectUsage(cfg config, repos []*client.Repository) (client.RepoUsage, error) {
collector := usage.Collector{
API: &gh,
From: from,
To: to,
From: cfg.from,
To: cfg.to,
Notify: func(message string) {
_, _ = fmt.Fprintln(os.Stderr, message)
},
Expand Down Expand Up @@ -179,6 +218,9 @@ func knownErrorMessage(err error) (string, bool) {
if unknownUser, ok := errors.AsType[UnknownUserError](err); ok {
return unknownUser.Error(), true
}
if invalidMonth, ok := errors.AsType[InvalidMonthError](err); ok {
return invalidMonth.Error(), true
}
if unexpectedHost, ok := errors.AsType[client.UnexpectedHostError](err); ok {
return unexpectedHost.Error(), true
}
Expand Down Expand Up @@ -252,8 +294,9 @@ func mapOwner(repos repoMap, userName string) error {
}

func printHelp() {
fmt.Println("USAGE: gh actions-usage [--output=human|tsv] [--skip] [--verbose] [target]...\n\n" +
"Gets the usage for all workflows in one or more GitHub repositories.\n\n" +
fmt.Println("USAGE: gh actions-usage [--month=YYYY-MM] [--output=human|tsv] [--skip] [--verbose] [target]...\n\n" +
"Gets the usage for all workflows in one or more GitHub repositories for the selected billing period,\n" +
"the current month by default.\n\n" +
"If target is not specified, actions-usage will attempt to get usage for a git repo in the current working directory.\n" +
"Target can be one of:\n" +
"- username (e.g. geoffreywiseman)\n" +
Expand Down
72 changes: 72 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import (
"fmt"
"io"
"testing"
"time"

"github.com/cli/go-gh/v2/pkg/api"
"github.com/geoffreywiseman/gh-actions-usage/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

var errGeneric = errors.New("something went wrong")
Expand Down Expand Up @@ -86,6 +88,76 @@ func TestPrintError_UnknownUser(t *testing.T) {
assert.Equal(t, "Unknown user: johndoe\n\n", out.String())
}

func TestPrintError_InvalidMonth(t *testing.T) {
// Given
var out bytes.Buffer
err := InvalidMonthError{Value: "2027-01", Reason: "the month has not started yet"}

// When
printError(cfgQuiet(&out), "Invalid option", err)

// Then
assert.Equal(t, "Invalid month \"2027-01\": the month has not started yet\n\n", out.String())
}

func TestParseMonth(t *testing.T) {
now := time.Date(2026, time.September, 9, 12, 34, 56, 789, time.UTC)
tests := []struct {
name string
value string
now time.Time
month time.Time
}{
{name: "current month", value: "2026-09", now: now, month: time.Date(2026, time.September, 1, 0, 0, 0, 0, time.UTC)},
{name: "past month", value: "2025-12", now: now, month: time.Date(2025, time.December, 1, 0, 0, 0, 0, time.UTC)},
{
name: "first instant of the month",
value: "2026-09",
now: time.Date(2026, time.September, 1, 0, 0, 0, 0, time.UTC),
month: time.Date(2026, time.September, 1, 0, 0, 0, 0, time.UTC),
},
{
name: "local evening is next month in UTC",
value: "2026-09",
now: time.Date(2026, time.August, 31, 22, 0, 0, 0, time.FixedZone("EST", -5*3600)),
month: time.Date(2026, time.September, 1, 0, 0, 0, 0, time.UTC),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
month, err := parseMonth(tc.value, tc.now)
require.NoError(t, err)
assert.Equal(t, tc.month, month)
assert.Equal(t, time.UTC, month.Location())
})
}
}

func TestParseMonth_Invalid(t *testing.T) {
now := time.Date(2026, time.September, 9, 12, 34, 56, 789, time.UTC)
tests := []struct {
name string
value string
reason string
}{
{name: "next month", value: "2026-10", reason: "the month has not started yet"},
{name: "next year", value: "2027-01", reason: "the month has not started yet"},
{name: "empty", value: "", reason: "expected a year and month such as 2026-09"},
{name: "unpadded month", value: "2026-9", reason: "expected a year and month such as 2026-09"},
{name: "month out of range", value: "2026-13", reason: "expected a year and month such as 2026-09"},
{name: "full date", value: "2026-09-01", reason: "expected a year and month such as 2026-09"},
{name: "month name", value: "September 2026", reason: "expected a year and month such as 2026-09"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := parseMonth(tc.value, now)
var invalid InvalidMonthError
require.ErrorAs(t, err, &invalid)
assert.Equal(t, InvalidMonthError{Value: tc.value, Reason: tc.reason}, invalid)
})
}
}

func TestPrintError_UnexpectedHost(t *testing.T) {
// Given
var out bytes.Buffer
Expand Down
15 changes: 11 additions & 4 deletions usage/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,18 @@ type Collector struct {
Notify func(message string)
}

// CurrentPeriod returns the bounds of the current metered billing period, which is the calendar month in UTC,
// from its first instant up to now.
func CurrentPeriod(now time.Time) (time.Time, time.Time) {
// Period returns the bounds of the metered billing period containing the instant month, which is the calendar
// month in UTC: from its first instant through its last second, or up to now when the month is the current one.
// The bounds of a month that has not started yet are not meaningful; callers should reject those first.
func Period(month, now time.Time) (time.Time, time.Time) {
month = month.UTC()
now = now.UTC().Truncate(time.Second)
return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC), now
from := time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, time.UTC)
to := from.AddDate(0, 1, 0).Add(-time.Second)
if to.After(now) {
to = now
}
return from, to
}

// repoPlan holds what was learned about a repository from the cheap listing calls, before check runs are fetched.
Expand Down
58 changes: 38 additions & 20 deletions usage/collector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,37 +69,55 @@ func plentyOfBudget(api *apiMock) {
api.On("GetRateLimit").Return(&client.RateLimit{Limit: 5000, Remaining: 4000}, nil)
}

func TestCurrentPeriod(t *testing.T) {
type test struct {
name string
now time.Time
from time.Time
to time.Time
func TestPeriod(t *testing.T) {
utc := func(year int, month time.Month, day, hour, minute, second int) time.Time {
return time.Date(year, month, day, hour, minute, second, 0, time.UTC)
}
est := time.FixedZone("EST", -5*3600)
tests := []test{
now := time.Date(2026, time.September, 9, 12, 34, 56, 789, time.UTC)
tests := []struct {
name string
month, now time.Time
from, to time.Time
}{
{
name: "current month runs up to now", month: utc(2026, time.September, 1, 0, 0, 0), now: now,
from: utc(2026, time.September, 1, 0, 0, 0), to: utc(2026, time.September, 9, 12, 34, 56),
},
{
name: "any instant in the month selects it", month: utc(2026, time.September, 23, 8, 15, 0), now: now,
from: utc(2026, time.September, 1, 0, 0, 0), to: utc(2026, time.September, 9, 12, 34, 56),
},
{
name: "first instant of month", month: utc(2026, time.February, 1, 0, 0, 0), now: utc(2026, time.February, 1, 0, 0, 0),
from: utc(2026, time.February, 1, 0, 0, 0), to: utc(2026, time.February, 1, 0, 0, 0),
},
{
name: "local evening is next month in UTC",
month: time.Date(2026, time.August, 31, 22, 0, 0, 0, est), now: time.Date(2026, time.August, 31, 22, 0, 0, 0, est),
from: utc(2026, time.September, 1, 0, 0, 0), to: utc(2026, time.September, 1, 3, 0, 0),
},
{
name: "past month runs through its last second", month: utc(2026, time.August, 1, 0, 0, 0), now: now,
from: utc(2026, time.August, 1, 0, 0, 0), to: utc(2026, time.August, 31, 23, 59, 59),
},
{
name: "mid month",
now: time.Date(2026, time.September, 9, 12, 34, 56, 789, time.UTC),
from: time.Date(2026, time.September, 1, 0, 0, 0, 0, time.UTC),
to: time.Date(2026, time.September, 9, 12, 34, 56, 0, time.UTC),
name: "leap February", month: utc(2024, time.February, 1, 0, 0, 0), now: now,
from: utc(2024, time.February, 1, 0, 0, 0), to: utc(2024, time.February, 29, 23, 59, 59),
},
{
name: "first instant of month",
now: time.Date(2026, time.February, 1, 0, 0, 0, 0, time.UTC),
from: time.Date(2026, time.February, 1, 0, 0, 0, 0, time.UTC),
to: time.Date(2026, time.February, 1, 0, 0, 0, 0, time.UTC),
name: "December ends before the new year", month: utc(2025, time.December, 1, 0, 0, 0), now: now,
from: utc(2025, time.December, 1, 0, 0, 0), to: utc(2025, time.December, 31, 23, 59, 59),
},
{
name: "local evening is next month in UTC",
now: time.Date(2026, time.August, 31, 22, 0, 0, 0, est),
from: time.Date(2026, time.September, 1, 0, 0, 0, 0, time.UTC),
to: time.Date(2026, time.September, 1, 3, 0, 0, 0, time.UTC),
name: "previous month is complete moments after it ended", month: utc(2026, time.August, 1, 0, 0, 0),
now: utc(2026, time.September, 1, 0, 0, 0),
from: utc(2026, time.August, 1, 0, 0, 0), to: utc(2026, time.August, 31, 23, 59, 59),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
from, to := CurrentPeriod(tc.now)
from, to := Period(tc.month, tc.now)
assert.Equal(t, tc.from, from)
assert.Equal(t, tc.to, to)
assert.Equal(t, time.UTC, from.Location())
Expand Down