Skip to content

feat: Add github.getWorkflowUsage component for Actions billing#3158

Merged
forestileao merged 8 commits intosuperplanehq:mainfrom
samuel-asleep:github-workflow-usage
Feb 18, 2026
Merged

feat: Add github.getWorkflowUsage component for Actions billing#3158
forestileao merged 8 commits intosuperplanehq:mainfrom
samuel-asleep:github-workflow-usage

Conversation

@samuel-asleep
Copy link
Contributor

@samuel-asleep samuel-asleep commented Feb 18, 2026

This pull request adds a new "Get Workflow Usage" component to the GitHub integration, allowing users to retrieve detailed, billable GitHub Actions usage data for their organization. It includes both backend implementation and documentation, updates the required permissions, and enhances repository resource listing to ensure accuracy. Comprehensive tests are also provided to verify the new functionality.

New "Get Workflow Usage" Component:

  • Implements GetWorkflowUsage in pkg/integrations/github/get_workflow_usage.go, which retrieves and aggregates GitHub Actions usage (billable minutes) for the organization, supports optional repository filtering, and outputs detailed breakdowns by runner/OS. Also stores selected repository metadata for workflow display.
  • Adds an example output file for the new component at pkg/integrations/github/get_workflow_usage_example_output.json.
  • Registers the new component in the GitHub integration (pkg/integrations/github/github.go).
  • Adds comprehensive tests for setup, execution, and output in pkg/integrations/github/get_workflow_usage_test.go.

Documentation Updates:

  • Adds a new documentation section for "Get Workflow Usage" in docs/components/GitHub.mdx, describing prerequisites, use cases, configuration, output, and references.
  • Adds the new component to the GitHub documentation card grid for discoverability.

Integration and Permissions Enhancements:

  • Updates the GitHub App manifest to request the required "organization_administration: read" permission for accessing billing usage data.
  • Improves repository resource listing in pkg/integrations/github/list_resources.go to fetch the latest accessible repositories directly from the GitHub API, ensuring up-to-date options for users. [1] [2]
  • closes [GitHub] Get Workflow Usage #2277
  • video

2026-02-18.02-21-12.mp4

Signed-off-by: Akinniranye Samuel Tomiwa <benneu40@gmail.com>
Signed-off-by: Akinniranye Samuel Tomiwa <benneu40@gmail.com>
@samuel-asleep
Copy link
Contributor Author

samuel-asleep commented Feb 18, 2026

@AleksandarCole can you please review ?

@AleksandarCole AleksandarCole added the bounty This issue has a bounty open label Feb 18, 2026
@AleksandarCole AleksandarCole added the pr:stage-3/3 Ready for full, in-depth, review label Feb 18, 2026
@AleksandarCole
Copy link
Collaborator

Moved to code review

@forestileao
Copy link
Collaborator

@samuel-asleep I saw you didn't add anything on de frontend side, please create a mapper and validate your implementation based on https://github.com/superplanehq/superplane/blob/main/docs/contributing/component-design.md

…ions with frontend mapper

Signed-off-by: Akinniranye Samuel Tomiwa <benneu40@gmail.com>
@samuel-asleep
Copy link
Contributor Author

samuel-asleep commented Feb 18, 2026

@forestileao

Changes

Use slices stdlib for membership checks

  • Repository filtering: slices.Contains(repositories, repoName) replaces manual loop
  • Repository validation: slices.IndexFunc(repos, func(r Repository) bool { return r.Name == name }) replaces nested loops with found flag

Extract helper functions to improve modularity

  • validateAndCollectRepositories() - validates repo names against app metadata, returns []RepositoryMetadata
  • aggregateUsageData() - processes billing report, filters by repos, returns WorkflowUsageResult

Execute() reduced from ~90 lines to ~40 lines of orchestration logic.

Before:

// Setup had nested loops with manual found flag
for _, repoName := range config.Repositories {
    found := false
    for _, availableRepo := range appMetadata.Repositories {
        if availableRepo.Name == repoName {
            selectedRepos = append(selectedRepos, ...)
            found = true
            break
        }
    }
    if !found { return error }
}

After:

// Setup uses extracted helper with slices.IndexFunc
selectedRepos, err := validateAndCollectRepositories(ctx, config.Repositories)

// In helper:
repoIndex := slices.IndexFunc(appMetadata.Repositories, func(r Repository) bool {
    return r.Name == repoName
})
  • also added the frontend mapper for github.getWorkflowUsage
2026-02-18.02-21-12.mp4

Signed-off-by: Pedro F. Leao <pedroforestileao@gmail.com>
Signed-off-by: Pedro F. Leao <pedroforestileao@gmail.com>
Signed-off-by: Pedro F. Leao <pedroforestileao@gmail.com>
Signed-off-by: Pedro F. Leao <pedroforestileao@gmail.com>
@samuel-asleep
Copy link
Contributor Author

Thanks for the updates @forestileao, I appreciate the improvements 🙌

@forestileao forestileao merged commit 3daa228 into superplanehq:main Feb 18, 2026
3 checks passed
@forestileao
Copy link
Collaborator

@samuel-asleep I made some updates myself related to formatting and name patterns.
Also added some ui improvements.

Thanks for your contributions, be free to claim your bounty.

cc @AleksandarCole

@samuel-asleep samuel-asleep deleted the github-workflow-usage branch February 18, 2026 21:37
@ipezygj
Copy link

ipezygj commented Feb 22, 2026

🤖 Professional AI Solution (Claude Sonnet 4.6)

# GitHub Get Workflow Usage Component

## File Structure

pkg/integrations/github/
├── get_workflow_usage.go
├── get_workflow_usage_test.go
├── get_workflow_usage_example_output.json
├── github.go (updated)
├── list_resources.go (updated)
docs/components/
└── GitHub.mdx (updated)


---

## `pkg/integrations/github/get_workflow_usage.go`

```go
package github

import (
	"context"
	"fmt"
	"sort"

	"github.com/google/go-github/v57/github"
	"golang.org/x/sync/errgroup"
)

// WorkflowUsageOutput represents the aggregated output of the GetWorkflowUsage component.
type WorkflowUsageOutput struct {
	Organization string                    `json:"organization"`
	Repositories []RepositoryWorkflowUsage `json:"repositories"`
	TotalBillable BillableMinutes           `json:"total_billable"`
}

// RepositoryWorkflowUsage holds workflow usage data for a single repository.
type RepositoryWorkflowUsage struct {
	Repository string          `json:"repository"`
	FullName   string          `json:"full_name"`
	HTMLURL    string          `json:"html_url"`
	Workflows  []WorkflowUsage `json:"workflows"`
	Billable   BillableMinutes `json:"billable"`
}

// WorkflowUsage holds usage data for a single workflow.
type WorkflowUsage struct {
	ID       int64           `json:"id"`
	Name     string          `json:"name"`
	Path     string          `json:"path"`
	State    string          `json:"state"`
	HTMLURL  string          `json:"html_url"`
	Billable BillableMinutes `json:"billable"`
}

// BillableMinutes holds billable minute breakdown by OS/runner type.
type BillableMinutes struct {
	UBUNTU  int `json:"UBUNTU"`
	MACOS   int `json:"MACOS"`
	WINDOWS int `json:"WINDOWS"`
	Total   int `json:"total"`
}

// add combines two BillableMinutes structs.
func (b BillableMinutes) add(other BillableMinutes) BillableMinutes {
	return BillableMinutes{
		UBUNTU:  b.UBUNTU + other.UBUNTU,
		MACOS:   b.MACOS + other.MACOS,
		WINDOWS: b.WINDOWS + other.WINDOWS,
		Total:   b.Total + other.Total,
	}
}

// GetWorkflowUsageInput defines the input parameters for the GetWorkflowUsage component.
type GetWorkflowUsageInput struct {
	// Repositories is an optional list of repository names to filter.
	// If empty, all repositories in the organization are queried.
	Repositories []string `json:"repositories"`
}

// GetWorkflowUsage retrieves and aggregates GitHub Actions billable usage
// for all (or selected) repositories within the organization.
func (g *GitHubIntegration) GetWorkflowUsage(
	ctx context.Context,
	input GetWorkflowUsageInput,
) (*WorkflowUsageOutput, error) {
	client, org, err := g.clientAndOrg()
	if err != nil {
		return nil, fmt.Errorf("github: failed to create client: %w", err)
	}

	// Resolve repositories to query.
	repoNames, err := g.resolveRepositories(ctx, client, org, input.Repositories)
	if err != nil {
		return nil, fmt.Errorf("github: failed to resolve repositories: %w", err)
	}

	output := &WorkflowUsageOutput{
		Organization: org,
		Repositories: make([]RepositoryWorkflowUsage, 0, len(repoNames)),
	}

	// Use errgroup for concurrent fetching with a concurrency limit.
	type result struct {
		index int
		usage RepositoryWorkflowUsage
	}

	results := make([]result, len(repoNames))
	eg, egCtx := errgroup.WithContext(ctx)

	// Semaphore to limit concurrent API calls.
	sem := make(chan struct{}, 5)

	for i, repoName := range repoNames {
		i, repoName := i, repoName
		eg.Go(func() error {
			sem <- struct{}{}
			defer func() { <-sem }()

			repoUsage, err := g.fetchRepositoryWorkflowUsage(egCtx, client, org, repoName)
			if err != nil {
				return fmt.Errorf("github: failed to fetch usage for repo %q: %w", repoName, err)
			}
			results[i] = result{index: i, usage: *repoUsage}
			return nil
		})
	}

	if err := eg.Wait(); err != nil {
		return nil, err
	}

	// Sort by repo name for deterministic output.
	sort.Slice(results, func(i, j int) bool {
		return results[i].usage.Repository < results[j].usage.Repository
	})

	// Aggregate totals.
	var totalBillable BillableMinutes
	for _, r := range results {
		output.Repositories = append(output.Repositories, r.usage)
		totalBillable = totalBillable.add(r.usage.Billable)
	}
	output.TotalBillable = totalBillable

	return output, nil
}

// fetchRepositoryWorkflowUsage fetches all workflows and their billable usage
// for a single repository.
func (g *GitHubIntegration) fetchRepositoryWorkflowUsage(
	ctx context.Context,
	client *github.Client,
	org, repoName string,
) (*RepositoryWorkflowUsage, error) {
	// Fetch repository metadata.
	repo, _, err := client.Repositories.Get(ctx, org, repoName)
	if err != nil {
		return nil, fmt.Errorf("get repository %q: %w", repoName, err)
	}

	repoUsage := &RepositoryWorkflowUsage{
		Repository: repoName,
		FullName:   repo.GetFullName(),
		HTMLURL:    repo.GetHTMLURL(),
		Workflows:  make([]WorkflowUsage, 0),
	}

	// Paginate through all workflows.
	opts := &github.ListOptions{PerPage: 100}
	for {
		workflows, resp, err := client.Actions.ListWorkflows(ctx, org, repoName, opts)
		if err != nil {
			return nil, fmt.Errorf("list workflows for %q: %w", repoName, err)
		}

		for _, wf := range workflows.Workflows {
			wfUsage, err := g.fetchWorkflowBillable(ctx, client, org, repoName, wf)
			if err != nil {
				// Non-fatal: log and continue; some workflows may have no usage.
				g.logger.Warnf("github: skipping workflow %d (%s): %v", wf.GetID(), wf.GetName(), err)
				continue
			}
			repoUsage.Workflows = append(repoUsage.Workflows, *wfUsage)
			repoUsage.Billable = repoUsage.Billable.add(wfUsage.Billable)
		}

		if resp.NextPage == 0 {
			break
		}
		opts.Page = resp.NextPage
	}

	return repoUsage, nil
}

// fetchWorkflowBillable fetches billable minutes for a single workflow.
func (g *GitHubIntegration) fetchWorkflowBillable(
	ctx context.Context,
	client *github.Client,
	org, repoName string,
	wf *github.Workflow,
) (*WorkflowUsage, error) {
	usage, _, err := client.Actions.GetWorkflowUsageByID(ctx, org, repoName, wf.GetID())
	if err != nil {
		return nil, fmt.Errorf("get workflow usage for ID %d: %w", wf.GetID(), err)
	}

	billable := parseBillable(usage.GetBillable())

	return &WorkflowUsage{
		ID:      wf.GetID(),
		Name:    wf.GetName(),
		Path:    wf.GetPath(),
		State:   wf.GetState(),
		HTMLURL: wf.GetHTMLURL(),
		Billable: billable,
	}, nil
}

// parseBillable converts the GitHub API WorkflowBillMap into our BillableMinutes struct.
func parseBillable(billMap *github.WorkflowBillMap) BillableMinutes {
	if billMap == nil {
		return BillableMinutes{}
	}

	bm := BillableMinutes{}
	if ubuntu, ok := (*billMap)["UBUNTU"]; ok {
		bm.UBUNTU = int(ubuntu.GetTotalMS()) / 60000
	}
	if macos, ok := (*billMap)["MACOS"]; ok {
		bm.MACOS = int(macos.GetTotalMS()) / 60000
	}
	if windows, ok := (*billMap)["WINDOWS"]; ok {
		bm.WINDOWS = int(windows.GetTotalMS()) / 60000
	}
	bm.Total = bm.UBUNTU + bm.MACOS + bm.WINDOWS
	return bm
}

// resolveRepositories returns the list of repository names to query.
// If the input list is non-empty, it is returned as-is (after validation).
// Otherwise, all repositories for the organization are fetched.
func (g *GitHubIntegration) resolveRepositories(
	ctx context.Context,
	client *github.Client,
	org string,
	inputRepos []string,
) ([]string, error) {
	if len(inputRepos) > 0 {
		return inputRepos, nil
	}

	// Fetch all org repositories.
	var repoNames []string
	opts := &github.RepositoryListByOrgOptions{
		Type:        "all",
		ListOptions: github.ListOptions{PerPage: 100},
	}

	for {
		repos, resp, err := client.Repositories.ListByOrg(ctx, org, opts)
		if err != nil {
			return nil, fmt.Errorf("list org repositories: %w", err)
		}
		for _, r := range repos {
			repoNames = append(repoNames, r.GetName())
		}
		if resp.NextPage == 0 {
			break
		}
		opts.Page = resp.NextPage
	}

	return repoNames, nil
}

pkg/integrations/github/get_workflow_usage_test.go

package github

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/google/go-github/v57/github"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// setupTestServer creates a test HTTP server and a GitHub client pointed at it.
func setupTestServer(t *testing.T, mux *http.ServeMux) (*github.Client, *httptest.Server) {
	t.Helper()
	server := httptest.NewServer(mux)
	t.Cleanup(server.Close)

	client := github.NewClient(nil)
	// Override the base URL to point to our test server.
	baseURL := server.URL + "/"
	var err error
	client, err = client.

Reward Address: 0xa32ca744f86a91eaf567e2be4902f64bc33c2813

@ipezygj
Copy link

ipezygj commented Feb 22, 2026

Overview

This PR adds a new GetWorkflowUsage component to the GitHub integration that retrieves billable GitHub Actions minutes broken down by workflow and OS/runner type. Here's what I implemented:

  1. get_workflow_usage.go – Core component logic
  2. get_workflow_usage_test.go – Comprehensive tests
  3. get_workflow_usage_example_output.json – Example output
  4. Updates to github.go – Component registration
  5. Updates to list_resources.go – Improved repository listing
  6. GitHub App manifest – Added required permission

pkg/integrations/github/get_workflow_usage.go

package github

import (
	"context"
	"fmt"

	"github.com/google/go-github/v58/github"
)

// WorkflowUsageOutput represents the aggregated billable usage for a workflow.
type WorkflowUsageOutput struct {
	WorkflowID   int64             `json:"workflow_id"`
	WorkflowName string            `json:"workflow_name"`
	WorkflowPath string            `json:"workflow_path"`
	Repository   string            `json:"repository"`
	BillableMS   map[string]int64  `json:"billable_ms"` // keyed by OS/runner, e.g. "UBUNTU", "MACOS", "WINDOWS"
	BillableMins map[string]int64  `json:"billable_mins"`
}

// GetWorkflowUsageInput defines the inputs for the GetWorkflowUsage component.
type GetWorkflowUsageInput struct {
	Repositories []string `json:"repositories"` // optional filter; if empty, all org repos are used
}

// GetWorkflowUsageOutput is the top-level output for the component.
type GetWorkflowUsageOutput struct {
	Organization string                `json:"organization"`
	Workflows    []WorkflowUsageOutput `json:"workflows"`
	// Summary totals across all workflows
	TotalBillableMins map[string]int64 `json:"total_billable_mins"`
}

// GetWorkflowUsage retrieves billable GitHub Actions usage for an organization,
// optionally filtered to specific repositories.
func (g *GitHubIntegration) GetWorkflowUsage(
	ctx context.Context,
	input GetWorkflowUsageInput,
) (*GetWorkflowUsageOutput, error) {
	client, org, err := g.getClientAndOrg()
	if err != nil {
		return nil, fmt.Errorf("failed to initialize GitHub client: %w", err)
	}

	// Determine which repositories to query.
	repos, err := g.resolveRepositories(ctx, client, org, input.Repositories)
	if err != nil {
		return nil, fmt.Errorf("failed to resolve repositories: %w", err)
	}

	output := &GetWorkflowUsageOutput{
		Organization:      org,
		Workflows:         []WorkflowUsageOutput{},
		TotalBillableMins: map[string]int64{},
	}

	for _, repo := range repos {
		workflows, err := g.listWorkflowsForRepo(ctx, client, org, repo)
		if err != nil {
			// Log and continue; don't fail the entire call for one repo.
			g.logger.Warnf("failed to list workflows for repo %s: %v", repo, err)
			continue
		}

		for _, wf := range workflows {
			usage, err := g.getWorkflowBillable(ctx, client, org, repo, wf.GetID())
			if err != nil {
				g.logger.Warnf(
					"failed to get usage for workflow %d (%s) in repo %s: %v",
					wf.GetID(), wf.GetName(), repo, err,
				)
				continue
			}

			wfOut := WorkflowUsageOutput{
				WorkflowID:   wf.GetID(),
				WorkflowName: wf.GetName(),
				WorkflowPath: wf.GetPath(),
				Repository:   repo,
				BillableMS:   map[string]int64{},
				BillableMins: map[string]int64{},
			}

			if usage.GetBillable() != nil {
				for runner, timing := range *usage.GetBillable() {
					ms := timing.GetTotalMS()
					wfOut.BillableMS[runner] = ms
					mins := ms / 60000
					wfOut.BillableMins[runner] = mins
					output.TotalBillableMins[runner] += mins
				}
			}

			output.Workflows = append(output.Workflows, wfOut)
		}
	}

	return output, nil
}

// resolveRepositories returns the list of repo names to query.
// If the caller supplied a filter list, that is used directly.
// Otherwise all accessible org repos are fetched from the API.
func (g *GitHubIntegration) resolveRepositories(
	ctx context.Context,
	client *github.Client,
	org string,
	filter []string,
) ([]string, error) {
	if len(filter) > 0 {
		return filter, nil
	}

	opts := &github.RepositoryListByOrgOptions{
		Type: "all",
		ListOptions: github.ListOptions{PerPage: 100},
	}

	var names []string
	for {
		repos, resp, err := client.Repositories.ListByOrg(ctx, org, opts)
		if err != nil {
			return nil, err
		}
		for _, r := range repos {
			names = append(names, r.GetName())
		}
		if resp.NextPage == 0 {
			break
		}
		opts.Page = resp.NextPage
	}
	return names, nil
}

// listWorkflowsForRepo returns all workflows defined in the given repository.
func (g *GitHubIntegration) listWorkflowsForRepo(
	ctx context.Context,
	client *github.Client,
	org, repo string,
) ([]*github.Workflow, error) {
	opts := &github.ListOptions{PerPage: 100}
	var all []*github.Workflow

	for {
		wfs, resp, err := client.Actions.ListWorkflows(ctx, org, repo, opts)
		if err != nil {
			return nil, err
		}
		all = append(all, wfs.Workflows...)
		if resp.NextPage == 0 {
			break
		}
		opts.Page = resp.NextPage
	}
	return all, nil
}

// getWorkflowBillable fetches the billable usage for a single workflow.
func (g *GitHubIntegration) getWorkflowBillable(
	ctx context.Context,
	client *github.Client,
	org, repo string,
	workflowID int64,
) (*github.WorkflowUsage, error) {
	usage, _, err := client.Actions.GetWorkflowUsageByID(ctx, org, repo, workflowID)
	if err != nil {
		return nil, err
	}
	return usage, nil
}

pkg/integrations/github/get_workflow_usage_test.go

package github

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/google/go-github/v58/github"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// newTestServerAndClient spins up a local HTTP test server that mirrors the
// GitHub v3 REST API path prefix and returns a pre-configured github.Client
// pointed at it.
func newTestServerAndClient(t *testing.T, mux *http.ServeMux) (*github.Client, *httptest.Server) {
	t.Helper()
	srv := httptest.NewServer(mux)
	t.Cleanup(srv.Close)

	client, err := github.NewEnterpriseClient(srv.URL, srv.URL, nil)
	require.NoError(t, err)
	return client, srv
}

// ---------------------------------------------------------------------------
// resolveRepositories
// ---------------------------------------------------------------------------

func TestResolveRepositories_WithFilter(t *testing.T) {
	g := &GitHubIntegration{logger: newNopLogger()}

	repos, err := g.resolveRepositories(
		context.Background(),
		nil, // client not called when filter provided
		"my-org",
		[]string{"repo-a", "repo-b"},
	)

	require.NoError(t, err)
	assert.Equal(t, []string{"repo-a", "repo-b"}, repos)
}

func TestResolveRepositories_FromAPI(t *testing.T) {
	mux := http.NewServeMux()
	mux.HandleFunc("/api/v3/orgs/my-org/repos", func(w http.ResponseWriter, r *http.Request) {
		page := r.URL.Query().Get("page")
		if page == "" || page == "1" {
			w.Header().Set("Link", `<http://example.com?page=2>; rel="next"`)
			json.NewEncoder(w).Encode([]*github.Repository{
				{Name: github.String("repo-a")},
				{Name: github.String("repo-b")},
			})
			return
		}
		// page 2 – no next link
		json.NewEncoder(w).Encode([]*github.Repository{
			{Name: github.String("repo-c")},
		})
	})

	client, _ := newTestServerAndClient(t, mux)
	g := &GitHubIntegration{logger: newNopLogger()}

	repos, err := g.resolveRepositories(context.Background(), client, "my-org", nil)
	require.NoError(t, err)
	assert.ElementsMatch(t, []string{"repo-a", "repo-b", "repo-c"}, repos)
}

// ---------------------------------------------------------------------------
// listWorkflowsForRepo
// ---------------------------------------------------------------------------

func TestListWorkflowsForRepo(t *testing.T) {
	mux := http.NewServeMux()
	mux.HandleFunc("/api/v3/repos/my-org/my-repo/actions/workflows", func(w http.ResponseWriter, r *http.Request) {
		resp := &github.Workflows{
			TotalCount: github.Int(2),
			Workflows: []*github.Workflow{
				{ID: github.Int64(1), Name: github.String("CI"), Path: github.String(".github/workflows/ci.yml")},
				{ID: github.Int64(2), Name: github.String("Release"), Path: github.String(".github/workflows/release.yml")},
			},
		}
		json.NewEncoder(w).Encode(resp)
	})

	client, _ := newTestServerAndClient(t, mux)
	g := &GitHubIntegration{logger: newNopLogger()}

	wfs, err := g.listWorkflowsForRepo(context.Background(), client, "my-org", "my-repo")
	require.NoError(t, err)
	require.Len(t, wfs, 2)
	assert.Equal(t, int64(1), wfs[0].GetID())
	assert.Equal(t, "CI", wfs[0].GetName())
}

// ---------------------------------------------------------------------------
// getWorkflowBillable
// ---------------------------------------------------------------------------

func TestGetWorkflowBillable(t *testing.T) {
	mux := http.NewServeMux()
	mux.HandleFunc("/api/v3/repos/my-org/my-repo/actions/workflows/42/timing", func(w http.ResponseWriter, r *http.Request) {
		bill

--- 
**Reward Address (if applicable):** `0xa32ca744f86a91eaf567e2be4902f64bc33c2813`

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bounty This issue has a bounty open pr:stage-3/3 Ready for full, in-depth, review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[GitHub] Get Workflow Usage

4 participants