From e3926ed4300a68a7592948e80eec757cd58b145f Mon Sep 17 00:00:00 2001 From: Geoffrey Wiseman Date: Fri, 11 Sep 2026 18:18:58 -0400 Subject: [PATCH] Add --month to report a past billing period The extension always reported the current billing period, which early in a month is a thin window; the interesting comparison is usually a completed month. A --month YYYY-MM flag now selects the calendar month in UTC to report, defaulting to the current one. A past month runs from its first instant through its last second; the current month runs up to now, as before. Values that do not parse as YYYY-MM, and months that have not started yet, are rejected through printError so the message prints cleanly without --verbose. usage.Period replaces CurrentPeriod and takes the selected month; the collector is unchanged apart from receiving its bounds from the config. The help text, README, and Copilot instructions now describe the selected billing period rather than the current one. Closes #54 Co-Authored-By: Claude Fable 5.1 --- .github/copilot-instructions.md | 6 +-- README.md | 17 +++++++- main.go | 61 +++++++++++++++++++++++----- main_test.go | 72 +++++++++++++++++++++++++++++++++ usage/collector.go | 15 +++++-- usage/collector_test.go | 58 +++++++++++++++++--------- 6 files changed, 191 insertions(+), 38 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 356c8e2..93bf8ee 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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. @@ -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. diff --git a/README.md b/README.md index c23d2b9..11bae8c 100644 --- a/README.md +++ b/README.md @@ -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. 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 @@ -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) +``` + Display the usage for a mix of repos using a tab-separated value format (TSV): ```shell @@ -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. diff --git a/main.go b/main.go index 87c5102..c6742ba 100644 --- a/main.go +++ b/main.go @@ -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 @@ -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 @@ -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 { @@ -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 @@ -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 @@ -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) }, @@ -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 } @@ -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" + diff --git a/main_test.go b/main_test.go index 7dd5b74..a555564 100644 --- a/main_test.go +++ b/main_test.go @@ -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") @@ -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 diff --git a/usage/collector.go b/usage/collector.go index 5ac6f81..cf2de7b 100644 --- a/usage/collector.go +++ b/usage/collector.go @@ -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. diff --git a/usage/collector_test.go b/usage/collector_test.go index 49cedfe..266a0e9 100644 --- a/usage/collector_test.go +++ b/usage/collector_test.go @@ -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())