Skip to content
Closed
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
4 changes: 2 additions & 2 deletions .github/workflows/ci-doctor.lock.yml

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

4 changes: 2 additions & 2 deletions .github/workflows/daily-team-status.lock.yml

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

2 changes: 1 addition & 1 deletion .github/workflows/super-linter.lock.yml

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

6 changes: 3 additions & 3 deletions pkg/workflow/action_pins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -345,9 +345,9 @@ func TestApplyActionPinToStep(t *testing.T) {
func TestGetActionPinsSorting(t *testing.T) {
pins := getActionPins()

// Verify we got all the pins (should be 18)
if len(pins) != 18 {
t.Errorf("getActionPins() returned %d pins, expected 18", len(pins))
// Verify we got all the pins (should be 20)
if len(pins) != 20 {
t.Errorf("getActionPins() returned %d pins, expected 20", len(pins))
}

// Verify they are sorted by version (descending) then by repository name (ascending)
Expand Down
8 changes: 6 additions & 2 deletions pkg/workflow/action_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func (r *ActionResolver) ResolveSHA(repo, version string) (string, error) {
}

// resolveFromGitHub uses gh CLI to resolve the SHA for an action@version
// Falls back to unauthenticated REST API if gh CLI fails due to authentication
func (r *ActionResolver) resolveFromGitHub(repo, version string) (string, error) {
// Extract base repository (for actions like "github/codeql-action/upload-sarif")
baseRepo := extractBaseRepo(repo)
Expand All @@ -60,13 +61,16 @@ func (r *ActionResolver) resolveFromGitHub(repo, version string) (string, error)
apiPath := fmt.Sprintf("/repos/%s/git/ref/tags/%s", baseRepo, version)
resolverLog.Printf("Querying GitHub API: %s", apiPath)

cmd := ExecGH("api", apiPath, "--jq", ".object.sha")
output, err := cmd.Output()
output, fromREST, err := ExecGHAPIWithRESTFallback(apiPath, ".object.sha")
if err != nil {
// Try without "refs/tags/" prefix in case version is already a ref
return "", fmt.Errorf("failed to resolve %s@%s: %w", repo, version, err)
}

if fromREST {
resolverLog.Printf("Resolved using REST API fallback")
}

sha := strings.TrimSpace(string(output))
if sha == "" {
return "", fmt.Errorf("empty SHA returned for %s@%s", repo, version)
Expand Down
192 changes: 192 additions & 0 deletions pkg/workflow/gh_helper.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
package workflow

import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strings"
"time"

"github.com/githubnext/gh-aw/pkg/logger"
)
Expand Down Expand Up @@ -39,3 +45,189 @@ func ExecGH(args ...string) *exec.Cmd {

return cmd
}

// ExecGHAPIWithRESTFallback executes a "gh api" command with fallback to unauthenticated REST API.
// This function is specialized for GitHub API calls only.
//
// When gh CLI fails due to missing/invalid authentication, this function will attempt
// to make the same API call using direct HTTP REST API without authentication.
//
// Args:
// - apiPath: GitHub API path (e.g., "/repos/owner/repo/git/ref/tags/v1.0")
// - jqFilter: Optional jq filter for JSON extraction (e.g., ".object.sha"), empty string if not needed
//
// Returns:
// - output: The command output (either from gh CLI or REST API)
// - fromREST: true if the output came from REST API fallback, false if from gh CLI
// - err: Error if both gh CLI and REST API failed
//
// Usage:
//
// output, fromREST, err := ExecGHAPIWithRESTFallback("/repos/actions/checkout/git/ref/tags/v4", ".object.sha")
func ExecGHAPIWithRESTFallback(apiPath, jqFilter string) ([]byte, bool, error) {
// Build gh api command arguments
args := []string{"api", apiPath}
if jqFilter != "" {
args = append(args, "--jq", jqFilter)
}

// First try with gh CLI
cmd := ExecGH(args...)
output, err := cmd.CombinedOutput()

if err == nil {
ghHelperLog.Printf("gh api succeeded")
return output, false, nil
}

// Check if error is authentication-related
exitErr, ok := err.(*exec.ExitError)
if !ok {
ghHelperLog.Printf("Not an exit error, returning original error")
return nil, false, err
}

// Common authentication error exit codes:
// - exit status 4: authentication error in gh CLI
// - exit status 1: general error (could be auth-related)
// Check both combined output and stderr for error messages
combinedOutput := string(output)
stderr := string(exitErr.Stderr)
isAuthError := exitErr.ExitCode() == 4 ||
strings.Contains(combinedOutput, "GH_TOKEN") ||
strings.Contains(combinedOutput, "authentication") ||
strings.Contains(stderr, "authentication") ||
strings.Contains(combinedOutput, "HTTP 401") ||
strings.Contains(stderr, "HTTP 401") ||
strings.Contains(combinedOutput, "HTTP 403") ||
strings.Contains(stderr, "HTTP 403") ||
strings.Contains(strings.ToLower(combinedOutput), "unauthorized") ||
strings.Contains(strings.ToLower(stderr), "unauthorized")

if !isAuthError {
ghHelperLog.Printf("Error is not authentication-related, returning original error: %v", combinedOutput)
return nil, false, exitErr
}

ghHelperLog.Printf("Authentication error detected, attempting REST API fallback")

// Attempt REST API call
restOutput, err := callGitHubRESTAPI(apiPath, jqFilter)
if err != nil {
ghHelperLog.Printf("REST API fallback failed: %v", err)
// Return original gh CLI error since REST API also failed
return nil, false, exitErr
}

ghHelperLog.Printf("REST API fallback succeeded")
return restOutput, true, nil
}

// callGitHubRESTAPI makes a direct HTTP call to the GitHub REST API without authentication.
// It handles jq filtering by parsing the JSON response and extracting the specified field.
func callGitHubRESTAPI(apiPath, jqFilter string) ([]byte, error) {
// Normalize API path (remove leading slash if present)
apiPath = strings.TrimPrefix(apiPath, "/")

// Build API URL
baseURL := "https://api.github.com"
url := fmt.Sprintf("%s/%s", baseURL, apiPath)

ghHelperLog.Printf("Making REST API call to: %s", url)

// Create HTTP client with timeout
client := &http.Client{
Timeout: 30 * time.Second,
}

// Create request
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

// Set headers
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("User-Agent", "gh-aw")

// Make request
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()

// Check status code
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
}

// Read response body
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}

// If jq filter is specified, extract the field
if jqFilter != "" {
filtered, err := applyJQFilter(body, jqFilter)
if err != nil {
return nil, fmt.Errorf("failed to apply jq filter %s: %w", jqFilter, err)
}
return filtered, nil
}

return body, nil
}

// applyJQFilter applies a simple jq-like filter to JSON data.
// It only supports simple field extraction like ".object.sha" or ".name"
func applyJQFilter(jsonData []byte, filter string) ([]byte, error) {
// Parse filter (e.g., ".object.sha" -> ["object", "sha"])
filter = strings.TrimPrefix(filter, ".")
fields := strings.Split(filter, ".")

// Parse JSON
var data interface{}
if err := json.Unmarshal(jsonData, &data); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}

// Navigate through fields
current := data
for _, field := range fields {
if field == "" {
continue
}

m, ok := current.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("cannot access field %s: not an object", field)
}

value, exists := m[field]
if !exists {
return nil, fmt.Errorf("field %s not found", field)
}

current = value
}

// Convert result to string
switch v := current.(type) {
case string:
return []byte(v + "\n"), nil
case float64:
return []byte(fmt.Sprintf("%v\n", v)), nil
case bool:
return []byte(fmt.Sprintf("%v\n", v)), nil
default:
// For complex types, return JSON
result, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("failed to marshal result: %w", err)
}
return append(result, '\n'), nil
}
}
Loading
Loading