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
8 changes: 8 additions & 0 deletions docs/src/content/docs/troubleshooting/debugging.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,14 @@ DEBUG=cli:* gh aw audit 12345678 # CLI-specific
DEBUG=workflow:*,cli:* gh aw compile # multiple packages
```

### Enable GitHub API Request Logging

`gh aw` uses go-gh's native REST/GraphQL clients for many GitHub API calls. Setting `GH_DEBUG=api` prints verbose request/response details for calls made through those go-gh clients, with no extra flags needed:

```bash
GH_DEBUG=api gh aw compile my-workflow
```

### Enable GitHub Actions Debug Logging

Add an `ACTIONS_STEP_DEBUG` repository secret set to `true` (**Settings → Secrets and variables → Actions**), then re-run the workflow for verbose step-level logging in the Actions UI.
Expand Down
1 change: 1 addition & 0 deletions pkg/cli/data/agentic_workflows_fallback_aw_files.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"debug-agentic-workflow.md",
"dependabot.md",
"deployment-status.md",
"designer-mappings.md",
"designer.md",
"evals.md",
"experiments.md",
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/js/exchange_otlp_workload_identity.cjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

71 changes: 60 additions & 11 deletions pkg/workflow/repository_features_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@
// # Validation Pattern: Feature Detection with Caching
//
// Repository feature validation uses a caching pattern to amortize expensive API calls:
// - sync.Map for thread-safe cache storage
// - sync.Map for thread-safe, same-process cache storage
// - sync.Once for single-fetch guarantee
// - Atomic LoadOrStore for race-free caching
// - Separate logged cache to avoid duplicate success messages
// - go-gh's disk-backed HTTP response cache (EnableCache/CacheTTL) as a second layer,
// persisting results across separate CLI invocations (e.g. repeated runs in the same
// CI workflow), on top of the in-process sync.Map fast path
//
// # When to Add Validation Here
//
Expand Down Expand Up @@ -58,6 +61,13 @@ import (
// (mirrors the copilot-billing probe timeout).
const repositoryFeaturesTimeout = 3 * time.Second

// repositoryFeaturesCacheTTL controls how long go-gh's disk-backed HTTP response cache
// keeps repository feature lookups (discussions/issues enablement) valid. These settings
// rarely change, so persisting results across process invocations (not just within a
// single process's in-memory sync.Map cache below) meaningfully reduces redundant API
// calls when the CLI is invoked repeatedly, e.g. across steps in the same CI workflow.
const repositoryFeaturesCacheTTL = 5 * time.Minute

var repositoryFeaturesLog = logger.New("workflow:repository_features_validation")

// checkRepositoryHasDiscussionsQuery is a hardcoded static GraphQL query template used to check
Expand Down Expand Up @@ -265,20 +275,35 @@ func checkRepositoryHasDiscussions(repo string, verbose bool) (bool, error) {
return features.HasDiscussions, nil
}

// checkRepositoryHasDiscussionsUncached checks if a repository has discussions enabled (no caching)
// checkRepositoryHasDiscussionsUncached checks if a repository has discussions enabled, bypassing
// only the in-process repositoryFeaturesCache/repositoryFeaturesLoggedCache layers.
// The underlying go-gh client still uses disk-backed HTTP response caching when enabled.
func checkRepositoryHasDiscussionsUncached(repo string) (bool, error) {
// Split repo into owner and name
parts := strings.SplitN(repo, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return false, fmt.Errorf("invalid repository format: %s. Expected format: owner/repo. Example: github/gh-aw", repo)
if err := validateRepositoryName(repo); err != nil {
return false, err
}
owner, name := parts[0], parts[1]

// Use native GraphQL client — no gh binary dependency, native context/cancel support.
client, err := api.DefaultGraphQLClient()
// EnableCache persists the (rarely-changing) discussions-enabled lookup to go-gh's
// disk-backed HTTP cache so repeated CLI invocations don't re-query the API.
Comment on lines +287 to +288
client, err := api.NewGraphQLClient(api.ClientOptions{
EnableCache: true,
CacheTTL: repositoryFeaturesCacheTTL,
})
Comment on lines +289 to +292
if err != nil {
return false, fmt.Errorf("failed to create GraphQL client: %w", err)
}
return checkRepositoryHasDiscussionsUncachedWithClient(repo, client)
}

// checkRepositoryHasDiscussionsUncachedWithClient is the testable core of
// checkRepositoryHasDiscussionsUncached. It accepts an injectable GraphQL client so
// unit tests can assert cache behavior without live credentials.
func checkRepositoryHasDiscussionsUncachedWithClient(repo string, client *api.GraphQLClient) (bool, error) {
owner, name, err := parseRepositoryName(repo)
if err != nil {
return false, err
}

var response struct {
Repository struct {
Expand All @@ -305,10 +330,20 @@ func checkRepositoryHasIssues(repo string, verbose bool) (bool, error) {
return features.HasIssues, nil
}

// checkRepositoryHasIssuesUncached checks if a repository has issues enabled (no caching)
// checkRepositoryHasIssuesUncached checks if a repository has issues enabled, bypassing
// only the in-process repositoryFeaturesCache/repositoryFeaturesLoggedCache layers.
// The underlying go-gh client still uses disk-backed HTTP response caching when enabled.
func checkRepositoryHasIssuesUncached(repo string) (bool, error) {
// Create REST client
client, err := api.DefaultRESTClient()
if err := validateRepositoryName(repo); err != nil {
return false, err
}

// Create REST client. EnableCache persists the (rarely-changing) has-issues lookup to
// go-gh's disk-backed HTTP cache so repeated CLI invocations don't re-query the API.
client, err := api.NewRESTClient(api.ClientOptions{
EnableCache: true,
CacheTTL: repositoryFeaturesCacheTTL,
})
if err != nil {
return false, fmt.Errorf("failed to create REST client: %w", err)
}
Expand All @@ -335,3 +370,17 @@ func checkRepositoryHasIssuesUncachedWithClient(repo string, client *api.RESTCli

return response.HasIssues, nil
}

func validateRepositoryName(repo string) error {
_, _, err := parseRepositoryName(repo)
return err
}

func parseRepositoryName(repo string) (owner string, name string, err error) {
parts := strings.SplitN(repo, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("invalid repository format: %s. Expected format: owner/repo. Example: github/gh-aw", repo)
}

return parts[0], parts[1], nil
}
127 changes: 127 additions & 0 deletions pkg/workflow/repository_features_validation_cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
//go:build !integration

package workflow

import (
"io"
"net/http"
"strings"
"sync/atomic"
"testing"

"github.com/cli/go-gh/v2/pkg/api"
)

type countingRoundTripper struct {
callCount int32
body string
}

func (c *countingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
atomic.AddInt32(&c.callCount, 1)
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(c.body)),
}, nil
}

func TestCheckRepositoryHasDiscussionsUncachedWithClient_UsesDiskCacheAcrossClients(t *testing.T) {
cacheDir := t.TempDir()

newClient := func(rt http.RoundTripper) *api.GraphQLClient {
t.Helper()
client, err := api.NewGraphQLClient(api.ClientOptions{
Host: "github.com",
AuthToken: "test-token",
EnableCache: true,
CacheTTL: repositoryFeaturesCacheTTL,
CacheDir: cacheDir,
Transport: rt,
})
if err != nil {
t.Fatalf("failed to create GraphQL client: %v", err)
}
return client
}

transport1 := &countingRoundTripper{
body: `{"data":{"repository":{"hasDiscussionsEnabled":true}}}`,
}

hasDiscussions, err := checkRepositoryHasDiscussionsUncachedWithClient("github/gh-aw", newClient(transport1))
if err != nil {
t.Fatalf("first query failed: %v", err)
}
if !hasDiscussions {
t.Fatal("expected discussions to be enabled")
}
if got := atomic.LoadInt32(&transport1.callCount); got != 1 {
t.Fatalf("expected first client to hit transport once, got %d", got)
}

transport2 := &countingRoundTripper{
body: `{"data":{"repository":{"hasDiscussionsEnabled":true}}}`,
}

hasDiscussions, err = checkRepositoryHasDiscussionsUncachedWithClient("github/gh-aw", newClient(transport2))
if err != nil {
t.Fatalf("second query failed: %v", err)
}
if !hasDiscussions {
t.Fatal("expected discussions to be enabled")
}
if got := atomic.LoadInt32(&transport2.callCount); got != 0 {
t.Fatalf("expected second client to use disk cache (no transport calls), got %d", got)
}
}

func TestCheckRepositoryHasIssuesUncachedWithClient_UsesDiskCacheAcrossClients(t *testing.T) {
cacheDir := t.TempDir()

newClient := func(rt http.RoundTripper) *api.RESTClient {
t.Helper()
client, err := api.NewRESTClient(api.ClientOptions{
Host: "github.com",
AuthToken: "test-token",
EnableCache: true,
CacheTTL: repositoryFeaturesCacheTTL,
CacheDir: cacheDir,
Transport: rt,
})
if err != nil {
t.Fatalf("failed to create REST client: %v", err)
}
return client
}

transport1 := &countingRoundTripper{
body: `{"has_issues":true}`,
}

hasIssues, err := checkRepositoryHasIssuesUncachedWithClient("github/gh-aw", newClient(transport1))
if err != nil {
t.Fatalf("first query failed: %v", err)
}
if !hasIssues {
t.Fatal("expected issues to be enabled")
}
if got := atomic.LoadInt32(&transport1.callCount); got != 1 {
t.Fatalf("expected first client to hit transport once, got %d", got)
}

transport2 := &countingRoundTripper{
body: `{"has_issues":true}`,
}

hasIssues, err = checkRepositoryHasIssuesUncachedWithClient("github/gh-aw", newClient(transport2))
if err != nil {
t.Fatalf("second query failed: %v", err)
}
if !hasIssues {
t.Fatal("expected issues to be enabled")
}
if got := atomic.LoadInt32(&transport2.callCount); got != 0 {
t.Fatalf("expected second client to use disk cache (no transport calls), got %d", got)
}
}
2 changes: 1 addition & 1 deletion pkg/workflow/repository_features_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ func TestCheckRepositoryInvalidFormat(t *testing.T) {

func TestCheckRepositoryHasIssuesUncached(t *testing.T) {
// Test the REST client code path directly
// This test exercises the api.DefaultRESTClient() and client.Get() path
// This test exercises the api.NewRESTClient() (with disk cache enabled) and client.DoWithContext() path
repo := "github/gh-aw"

hasIssues, err := checkRepositoryHasIssuesUncached(repo)
Expand Down