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
3 changes: 3 additions & 0 deletions cmd/gh-aw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ Examples:
stats, _ := cmd.Flags().GetBool("stats")
failFast, _ := cmd.Flags().GetBool("fail-fast")
noCheckUpdate, _ := cmd.Flags().GetBool("no-check-update")
scheduleSeed, _ := cmd.Flags().GetString("schedule-seed")
verbose, _ := cmd.Flags().GetBool("verbose")
if err := validateEngine(engineOverride); err != nil {
return err
Expand Down Expand Up @@ -333,6 +334,7 @@ Examples:
JSONOutput: jsonOutput,
Stats: stats,
FailFast: failFast,
ScheduleSeed: scheduleSeed,
}
if _, err := cli.CompileWorkflows(cmd.Context(), config); err != nil {
// Return error as-is without additional formatting
Expand Down Expand Up @@ -678,6 +680,7 @@ Use "` + string(constants.CLIExtensionPrefix) + ` help all" to show help for all
compileCmd.Flags().Bool("stats", false, "Display statistics table sorted by file size (shows jobs, steps, scripts, and shells)")
compileCmd.Flags().Bool("fail-fast", false, "Stop at the first validation error instead of collecting all errors")
compileCmd.Flags().Bool("no-check-update", false, "Skip checking for gh-aw updates")
compileCmd.Flags().String("schedule-seed", "", "Override the repository slug (owner/repo) used as seed for fuzzy schedule scattering (e.g. 'github/gh-aw'). Bypasses git remote detection entirely. Use this when your git remote is not named 'origin' and you have multiple remotes configured")
compileCmd.MarkFlagsMutuallyExclusive("dir", "workflows-dir")

// Register completions for compile command
Expand Down
56 changes: 9 additions & 47 deletions pkg/agentdrain/data/default_weights.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,7 @@
],
"config": {
"Depth": 4,
"ExcludeFields": [
"session_id",
"trace_id",
"span_id",
"timestamp"
],
"ExcludeFields": ["session_id", "trace_id", "span_id", "timestamp"],
"MaskRules": [
{
"Name": "uuid",
Expand Down Expand Up @@ -158,21 +153,12 @@
"id": 1,
"size": 50,
"stage": "finish",
"template": [
"stage=finish",
"\u003c*\u003e",
"tokens=\u003cNUM\u003e"
]
"template": ["stage=finish", "\u003c*\u003e", "tokens=\u003cNUM\u003e"]
}
],
"config": {
"Depth": 4,
"ExcludeFields": [
"session_id",
"trace_id",
"span_id",
"timestamp"
],
"ExcludeFields": ["session_id", "trace_id", "span_id", "timestamp"],
"MaskRules": [
{
"Name": "uuid",
Expand Down Expand Up @@ -218,21 +204,12 @@
"id": 1,
"size": 22,
"stage": "plan",
"template": [
"stage=plan",
"errors=\u003cNUM\u003e",
"turns=\u003cNUM\u003e"
]
"template": ["stage=plan", "errors=\u003cNUM\u003e", "turns=\u003cNUM\u003e"]
}
],
"config": {
"Depth": 4,
"ExcludeFields": [
"session_id",
"trace_id",
"span_id",
"timestamp"
],
"ExcludeFields": ["session_id", "trace_id", "span_id", "timestamp"],
"MaskRules": [
{
"Name": "uuid",
Expand Down Expand Up @@ -276,12 +253,7 @@
"clusters": null,
"config": {
"Depth": 4,
"ExcludeFields": [
"session_id",
"trace_id",
"span_id",
"timestamp"
],
"ExcludeFields": ["session_id", "trace_id", "span_id", "timestamp"],
"MaskRules": [
{
"Name": "uuid",
Expand Down Expand Up @@ -325,12 +297,7 @@
"clusters": null,
"config": {
"Depth": 4,
"ExcludeFields": [
"session_id",
"trace_id",
"span_id",
"timestamp"
],
"ExcludeFields": ["session_id", "trace_id", "span_id", "timestamp"],
"MaskRules": [
{
"Name": "uuid",
Expand Down Expand Up @@ -644,12 +611,7 @@
],
"config": {
"Depth": 4,
"ExcludeFields": [
"session_id",
"trace_id",
"span_id",
"timestamp"
],
"ExcludeFields": ["session_id", "trace_id", "span_id", "timestamp"],
"MaskRules": [
{
"Name": "uuid",
Expand Down Expand Up @@ -689,4 +651,4 @@
},
"next_id": 6
}
}
}
22 changes: 20 additions & 2 deletions pkg/cli/compile_compiler_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/logger"
"github.com/github/gh-aw/pkg/workflow"
)
Expand Down Expand Up @@ -111,7 +113,7 @@ func createAndConfigureCompiler(config CompileConfig) *workflow.Compiler {
}

// Set up repository context
setupRepositoryContext(compiler)
setupRepositoryContext(compiler, config)

return compiler
}
Expand Down Expand Up @@ -195,9 +197,25 @@ func setupActionMode(compiler *workflow.Compiler, actionMode string, actionTag s
}

// setupRepositoryContext sets the repository slug for schedule scattering
func setupRepositoryContext(compiler *workflow.Compiler) {
func setupRepositoryContext(compiler *workflow.Compiler, config CompileConfig) {
compileCompilerSetupLog.Print("Setting up repository context")

// If a schedule seed is explicitly provided, use it directly
if config.ScheduleSeed != "" {
// Validate owner/repo format: must contain exactly one '/' with non-empty parts
parts := strings.SplitN(config.ScheduleSeed, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
compileCompilerSetupLog.Printf("Invalid --schedule-seed value %q: expected 'owner/repo' format", config.ScheduleSeed)
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(
fmt.Sprintf("--schedule-seed %q is not in 'owner/repo' format; ignoring and falling back to git remote detection", config.ScheduleSeed),
))
} else {
compiler.SetRepositorySlug(config.ScheduleSeed)
compileCompilerSetupLog.Printf("Repository slug overridden via --schedule-seed: %s", config.ScheduleSeed)
Comment on lines +205 to +214
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--schedule-seed validation does not enforce the documented “exactly one '/'” requirement. Using strings.SplitN(seed, "/", 2) allows values like owner/repo/extra to be treated as valid and used as the repository slug, which can lead to unexpected scattering seeds. Consider validating with strings.Count(seed, "/") == 1 (or splitting on all '/' and requiring exactly 2 non-empty parts) and optionally trimming surrounding whitespace before validation.

Suggested change
// Validate owner/repo format: must contain exactly one '/' with non-empty parts
parts := strings.SplitN(config.ScheduleSeed, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
compileCompilerSetupLog.Printf("Invalid --schedule-seed value %q: expected 'owner/repo' format", config.ScheduleSeed)
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(
fmt.Sprintf("--schedule-seed %q is not in 'owner/repo' format; ignoring and falling back to git remote detection", config.ScheduleSeed),
))
} else {
compiler.SetRepositorySlug(config.ScheduleSeed)
compileCompilerSetupLog.Printf("Repository slug overridden via --schedule-seed: %s", config.ScheduleSeed)
seed := strings.TrimSpace(config.ScheduleSeed)
// Validate owner/repo format: must contain exactly one '/' with non-empty parts
parts := strings.Split(seed, "/")
if strings.Count(seed, "/") != 1 || len(parts) != 2 || parts[0] == "" || parts[1] == "" {
compileCompilerSetupLog.Printf("Invalid --schedule-seed value %q: expected 'owner/repo' format", config.ScheduleSeed)
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(
fmt.Sprintf("--schedule-seed %q is not in 'owner/repo' format; ignoring and falling back to git remote detection", config.ScheduleSeed),
))
} else {
compiler.SetRepositorySlug(seed)
compileCompilerSetupLog.Printf("Repository slug overridden via --schedule-seed: %s", seed)

Copilot uses AI. Check for mistakes.
return
}
Comment on lines +203 to +216
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New --schedule-seed behavior in setupRepositoryContext isn’t covered by tests yet. Since schedule scattering behavior depends on Compiler.repositorySlug, consider adding unit tests that (1) a valid seed sets the repository slug and bypasses git detection, and (2) an invalid seed emits a warning and falls back to remote-based detection.

Copilot uses AI. Check for mistakes.
}

// Set repository slug for schedule scattering
repoSlug := getRepositorySlugFromRemote()
if repoSlug != "" {
Expand Down
1 change: 1 addition & 0 deletions pkg/cli/compile_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type CompileConfig struct {
ActionsRepo string // Override the external actions repository (default: github/gh-aw-actions)
Stats bool // Display statistics table sorted by file size
FailFast bool // Stop at first error instead of collecting all errors
ScheduleSeed string // Override repository slug used for fuzzy schedule scattering (e.g. owner/repo)
}

// WorkflowFailure represents a failed workflow with its error count
Expand Down
88 changes: 66 additions & 22 deletions pkg/cli/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,39 +118,85 @@ func extractHostFromRemoteURL(remoteURL string) string {
return "github.com"
}

// getHostFromOriginRemote returns the hostname of the git origin remote.
// resolveRemoteURL resolves the best git remote URL to use for a given directory.
// It first tries the 'origin' remote for backward compatibility. If 'origin' is not
// configured but exactly one other remote exists, that remote is used instead.
// Returns the remote URL, the remote name used, and any error.
// dir may be empty to use the current working directory.
func resolveRemoteURL(dir string) (string, string, error) {
gitArgs := func(args ...string) *exec.Cmd {
if dir != "" {
return exec.Command("git", append([]string{"-C", dir}, args...)...)
}
return exec.Command("git", args...)
}

// First try 'origin' for backward compatibility
if output, err := gitArgs("config", "--get", "remote.origin.url").Output(); err == nil {
url := strings.TrimSpace(string(output))
if url != "" {
gitLog.Print("Using 'origin' remote")
return url, "origin", nil
}
}
Comment on lines +134 to +141
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

git config --get remote.<name>.url can return multiple lines when multiple URLs are configured for a remote. In that case strings.TrimSpace(string(output)) will contain embedded newlines, causing parseGitHubRepoSlugFromURL / extractHostFromRemoteURL to fail. Consider using --get-urlmatch/--get-all handling explicitly and selecting the first URL line (or erroring with a clear message) before returning from resolveRemoteURL.

Copilot uses AI. Check for mistakes.

// Fall back: list all remotes
output, err := gitArgs("remote").Output()
if err != nil {
return "", "", fmt.Errorf("failed to list git remotes: %w", err)
}

remoteNames := strings.Fields(strings.TrimSpace(string(output)))
if len(remoteNames) == 0 {
return "", "", errors.New("no git remotes configured")
}
if len(remoteNames) > 1 {
return "", "", fmt.Errorf("multiple git remotes configured (%s), no 'origin' remote found", strings.Join(remoteNames, ", "))
}

// Exactly one remote — use it
remoteName := remoteNames[0]
urlOutput, err := gitArgs("config", "--get", "remote."+remoteName+".url").Output()
if err != nil {
return "", "", fmt.Errorf("failed to get URL for remote %q: %w", remoteName, err)
}

url := strings.TrimSpace(string(urlOutput))
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When falling back to the single configured remote, resolveRemoteURL returns success even if the resolved URL is empty (it doesn’t perform the non-empty check that the origin branch does). This can silently propagate an empty remote URL to callers. Consider validating url != "" here and returning an error if the remote has no URL configured.

Suggested change
url := strings.TrimSpace(string(urlOutput))
url := strings.TrimSpace(string(urlOutput))
if url == "" {
return "", "", fmt.Errorf("remote %q has no URL configured", remoteName)
}

Copilot uses AI. Check for mistakes.
gitLog.Printf("No 'origin' remote found; using single configured remote %q", remoteName)
return url, remoteName, nil
}

// getHostFromOriginRemote returns the hostname of the git remote.
// It prefers the 'origin' remote for backward compatibility. If 'origin' is not
// configured but exactly one other remote exists, that remote is used instead.
// For example, a remote URL of "https://ghes.example.com/org/repo.git" returns "ghes.example.com",
// and "git@github.com:owner/repo.git" returns "github.com".
// Returns "github.com" as the default if the remote URL cannot be determined.
func getHostFromOriginRemote() string {
cmd := exec.Command("git", "config", "--get", "remote.origin.url")
output, err := cmd.Output()
remoteURL, remoteName, err := resolveRemoteURL("")
Comment on lines +169 to +176
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getHostFromOriginRemote now falls back to a non-origin remote when origin is absent, so the function name is no longer accurate. If this is an internal API, consider renaming to something like getHostFromRemote and keeping getHostFromOriginRemote as a thin backward-compatible wrapper (or update call sites to the new name) to avoid future confusion.

Copilot uses AI. Check for mistakes.
if err != nil {
gitLog.Printf("Failed to get remote origin URL: %v", err)
gitLog.Printf("Failed to resolve remote URL: %v", err)
return "github.com"
}

remoteURL := strings.TrimSpace(string(output))
host := extractHostFromRemoteURL(remoteURL)
gitLog.Printf("Detected GitHub host from remote origin: %s", host)
gitLog.Printf("Detected GitHub host from remote %q: %s", remoteName, host)
return host
}

// getRepositorySlugFromRemote extracts the repository slug (owner/repo) from git remote URL
// getRepositorySlugFromRemote extracts the repository slug (owner/repo) from git remote URL.
// It prefers the 'origin' remote for backward compatibility. If 'origin' is not
// configured but exactly one other remote exists, that remote is used instead.
func getRepositorySlugFromRemote() string {
gitLog.Print("Getting repository slug from git remote")

// Try to get from git remote URL
cmd := exec.Command("git", "config", "--get", "remote.origin.url")
output, err := cmd.Output()
remoteURL, _, err := resolveRemoteURL("")
if err != nil {
gitLog.Printf("Failed to get remote URL: %v", err)
gitLog.Printf("Failed to resolve remote URL: %v", err)
return ""
}

url := strings.TrimSpace(string(output))
slug := parseGitHubRepoSlugFromURL(url)

slug := parseGitHubRepoSlugFromURL(remoteURL)
if slug != "" {
gitLog.Printf("Repository slug: %s", slug)
}
Expand All @@ -159,7 +205,9 @@ func getRepositorySlugFromRemote() string {
}

// getRepositorySlugFromRemoteForPath extracts the repository slug (owner/repo) from the git remote URL
// of the repository containing the specified file path
// of the repository containing the specified file path.
// It prefers the 'origin' remote for backward compatibility. If 'origin' is not
// configured but exactly one other remote exists, that remote is used instead.
func getRepositorySlugFromRemoteForPath(path string) string {
gitLog.Printf("Getting repository slug for path: %s", path)

Expand All @@ -180,17 +228,13 @@ func getRepositorySlugFromRemoteForPath(path string) string {
// Use the directory containing the file
dir := filepath.Dir(absPath)

// Try to get from git remote URL in the file's repository
cmd := exec.Command("git", "-C", dir, "config", "--get", "remote.origin.url")
output, err := cmd.Output()
remoteURL, _, err := resolveRemoteURL(dir)
if err != nil {
gitLog.Printf("Failed to get remote URL for path: %v", err)
gitLog.Printf("Failed to resolve remote URL for path: %v", err)
return ""
}

url := strings.TrimSpace(string(output))
slug := parseGitHubRepoSlugFromURL(url)

slug := parseGitHubRepoSlugFromURL(remoteURL)
if slug != "" {
gitLog.Printf("Repository slug for path: %s", slug)
}
Expand Down
Loading
Loading