From 0588d20fde38fd78a312cfc45006df061bce6bf8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:14:48 +0000 Subject: [PATCH 1/3] Initial plan From 0e6402e0146eb8f4ecf846bb900290e04cb83d4e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:31:02 +0000 Subject: [PATCH 2/3] fix: fuzzy schedule remote detection now works with non-origin remotes The fuzzy schedule scattering previously required a remote named 'origin' to determine the repository context. This commit adds a smarter fallback: 1. First tries the 'origin' remote for backward compatibility 2. If 'origin' is not configured but exactly one other remote exists, that remote is used automatically 3. If multiple remotes exist without 'origin', falls through to the existing warning behavior Adds a new `resolveRemoteURL(dir)` helper function that encapsulates this logic, and updates `getRepositorySlugFromRemote()`, `getRepositorySlugFromRemoteForPath()`, and `getHostFromOriginRemote()` to use it. Fixes: fuzzy schedule scattering without repository context warning when using non-'origin' remote names. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/b2039ff1-76de-419b-9155-60b3110faf4b Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/agentdrain/data/default_weights.json | 56 ++----- pkg/cli/git.go | 88 ++++++++--- pkg/cli/git_test.go | 183 +++++++++++++++++++++++ 3 files changed, 258 insertions(+), 69 deletions(-) diff --git a/pkg/agentdrain/data/default_weights.json b/pkg/agentdrain/data/default_weights.json index 77f3b211613..be28f3108b2 100644 --- a/pkg/agentdrain/data/default_weights.json +++ b/pkg/agentdrain/data/default_weights.json @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", @@ -689,4 +651,4 @@ }, "next_id": 6 } -} \ No newline at end of file +} diff --git a/pkg/cli/git.go b/pkg/cli/git.go index 178fa741228..53e2c286b49 100644 --- a/pkg/cli/git.go +++ b/pkg/cli/git.go @@ -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 + } + } + + // 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)) + 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("") 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) } @@ -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) @@ -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) } diff --git a/pkg/cli/git_test.go b/pkg/cli/git_test.go index 73b42c4845a..7ce304c4459 100644 --- a/pkg/cli/git_test.go +++ b/pkg/cli/git_test.go @@ -579,4 +579,187 @@ func TestGetHostFromOriginRemote(t *testing.T) { t.Errorf("getHostFromOriginRemote() = %q, want %q", got, "ghes.example.com") } }) + + t.Run("non-origin single remote falls back to that remote", func(t *testing.T) { + if err := exec.Command("git", "remote", "add", "upstream", "https://github.com/owner/repo.git").Run(); err != nil { + t.Fatalf("Failed to add remote: %v", err) + } + defer func() { _ = exec.Command("git", "remote", "remove", "upstream").Run() }() + + got := getHostFromOriginRemote() + if got != "github.com" { + t.Errorf("getHostFromOriginRemote() with non-origin remote = %q, want %q", got, "github.com") + } + }) + + t.Run("multiple remotes without origin defaults to github.com", func(t *testing.T) { + if err := exec.Command("git", "remote", "add", "myorg", "https://github.com/myorg/repo.git").Run(); err != nil { + t.Fatalf("Failed to add first remote: %v", err) + } + defer func() { _ = exec.Command("git", "remote", "remove", "myorg").Run() }() + if err := exec.Command("git", "remote", "add", "other", "https://github.com/other/repo.git").Run(); err != nil { + t.Fatalf("Failed to add second remote: %v", err) + } + defer func() { _ = exec.Command("git", "remote", "remove", "other").Run() }() + + got := getHostFromOriginRemote() + if got != "github.com" { + t.Errorf("getHostFromOriginRemote() with multiple non-origin remotes = %q, want %q", got, "github.com") + } + }) +} + +func TestResolveRemoteURL(t *testing.T) { + tmpDir := testutil.TempDir(t, "test-resolve-remote-*") + + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { + if err := os.Chdir(originalDir); err != nil { + t.Logf("Warning: failed to restore directory: %v", err) + } + }() + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + + // Initialize a git repo + if err := exec.Command("git", "init").Run(); err != nil { + t.Skip("Git not available") + } + + t.Run("no remotes returns error", func(t *testing.T) { + _, _, err := resolveRemoteURL("") + if err == nil { + t.Error("resolveRemoteURL() should return error when no remotes are configured") + } + }) + + t.Run("origin remote is used when present", func(t *testing.T) { + if err := exec.Command("git", "remote", "add", "origin", "https://github.com/owner/repo.git").Run(); err != nil { + t.Fatalf("Failed to add remote: %v", err) + } + defer func() { _ = exec.Command("git", "remote", "remove", "origin").Run() }() + + url, name, err := resolveRemoteURL("") + if err != nil { + t.Fatalf("resolveRemoteURL() failed: %v", err) + } + if name != "origin" { + t.Errorf("resolveRemoteURL() remote name = %q, want %q", name, "origin") + } + if url != "https://github.com/owner/repo.git" { + t.Errorf("resolveRemoteURL() URL = %q, want %q", url, "https://github.com/owner/repo.git") + } + }) + + t.Run("single non-origin remote is used as fallback", func(t *testing.T) { + if err := exec.Command("git", "remote", "add", "myorg", "https://github.com/myorg/repo.git").Run(); err != nil { + t.Fatalf("Failed to add remote: %v", err) + } + defer func() { _ = exec.Command("git", "remote", "remove", "myorg").Run() }() + + url, name, err := resolveRemoteURL("") + if err != nil { + t.Fatalf("resolveRemoteURL() failed: %v", err) + } + if name != "myorg" { + t.Errorf("resolveRemoteURL() remote name = %q, want %q", name, "myorg") + } + if url != "https://github.com/myorg/repo.git" { + t.Errorf("resolveRemoteURL() URL = %q, want %q", url, "https://github.com/myorg/repo.git") + } + }) + + t.Run("multiple non-origin remotes returns error", func(t *testing.T) { + if err := exec.Command("git", "remote", "add", "remote1", "https://github.com/org1/repo.git").Run(); err != nil { + t.Fatalf("Failed to add first remote: %v", err) + } + defer func() { _ = exec.Command("git", "remote", "remove", "remote1").Run() }() + if err := exec.Command("git", "remote", "add", "remote2", "https://github.com/org2/repo.git").Run(); err != nil { + t.Fatalf("Failed to add second remote: %v", err) + } + defer func() { _ = exec.Command("git", "remote", "remove", "remote2").Run() }() + + _, _, err := resolveRemoteURL("") + if err == nil { + t.Error("resolveRemoteURL() should return error when multiple non-origin remotes are configured") + } + }) + + t.Run("origin takes precedence when multiple remotes exist", func(t *testing.T) { + if err := exec.Command("git", "remote", "add", "origin", "https://github.com/owner/repo.git").Run(); err != nil { + t.Fatalf("Failed to add origin remote: %v", err) + } + defer func() { _ = exec.Command("git", "remote", "remove", "origin").Run() }() + if err := exec.Command("git", "remote", "add", "upstream", "https://github.com/upstream/repo.git").Run(); err != nil { + t.Fatalf("Failed to add upstream remote: %v", err) + } + defer func() { _ = exec.Command("git", "remote", "remove", "upstream").Run() }() + + url, name, err := resolveRemoteURL("") + if err != nil { + t.Fatalf("resolveRemoteURL() failed: %v", err) + } + if name != "origin" { + t.Errorf("resolveRemoteURL() remote name = %q, want %q", name, "origin") + } + if url != "https://github.com/owner/repo.git" { + t.Errorf("resolveRemoteURL() URL = %q, want %q", url, "https://github.com/owner/repo.git") + } + }) +} + +func TestGetRepositorySlugFromRemoteFallback(t *testing.T) { + tmpDir := testutil.TempDir(t, "test-slug-fallback-*") + + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { + if err := os.Chdir(originalDir); err != nil { + t.Logf("Warning: failed to restore directory: %v", err) + } + }() + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + + // Initialize a git repo + if err := exec.Command("git", "init").Run(); err != nil { + t.Skip("Git not available") + } + + t.Run("single non-origin remote provides repo slug", func(t *testing.T) { + if err := exec.Command("git", "remote", "add", "myorg", "https://github.com/myorg/myrepo.git").Run(); err != nil { + t.Fatalf("Failed to add remote: %v", err) + } + defer func() { _ = exec.Command("git", "remote", "remove", "myorg").Run() }() + + slug := getRepositorySlugFromRemote() + if slug != "myorg/myrepo" { + t.Errorf("getRepositorySlugFromRemote() = %q, want %q", slug, "myorg/myrepo") + } + }) + + t.Run("multiple non-origin remotes returns empty slug", func(t *testing.T) { + if err := exec.Command("git", "remote", "add", "remote1", "https://github.com/org1/repo1.git").Run(); err != nil { + t.Fatalf("Failed to add first remote: %v", err) + } + defer func() { _ = exec.Command("git", "remote", "remove", "remote1").Run() }() + if err := exec.Command("git", "remote", "add", "remote2", "https://github.com/org2/repo2.git").Run(); err != nil { + t.Fatalf("Failed to add second remote: %v", err) + } + defer func() { _ = exec.Command("git", "remote", "remove", "remote2").Run() }() + + slug := getRepositorySlugFromRemote() + if slug != "" { + t.Errorf("getRepositorySlugFromRemote() with multiple non-origin remotes = %q, want %q", slug, "") + } + }) } From 01b666832e5fbd6d10d310ac84f17ee072fccd14 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:44:00 +0000 Subject: [PATCH 3/3] feat: add --schedule-seed flag to compile command Allows users to override the repository slug used as the seed for fuzzy schedule scattering, bypassing git remote detection entirely. This is useful when no 'origin' remote is configured and multiple remotes exist (where automatic detection is ambiguous). Usage: gh aw compile --schedule-seed owner/repo The value is validated to be in 'owner/repo' format; an invalid value emits a warning and falls back to automatic detection. Agent-Logs-Url: https://github.com/github/gh-aw/sessions/301bc69e-22a6-4499-8a41-de8b59db9c50 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- cmd/gh-aw/main.go | 3 +++ pkg/cli/compile_compiler_setup.go | 22 ++++++++++++++++++++-- pkg/cli/compile_config.go | 1 + 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index 7263a1a7c22..db94f8c469b 100644 --- a/cmd/gh-aw/main.go +++ b/cmd/gh-aw/main.go @@ -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 @@ -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 @@ -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 diff --git a/pkg/cli/compile_compiler_setup.go b/pkg/cli/compile_compiler_setup.go index 3a18ae5f64f..0addb763aab 100644 --- a/pkg/cli/compile_compiler_setup.go +++ b/pkg/cli/compile_compiler_setup.go @@ -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" ) @@ -111,7 +113,7 @@ func createAndConfigureCompiler(config CompileConfig) *workflow.Compiler { } // Set up repository context - setupRepositoryContext(compiler) + setupRepositoryContext(compiler, config) return compiler } @@ -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) + return + } + } + // Set repository slug for schedule scattering repoSlug := getRepositorySlugFromRemote() if repoSlug != "" { diff --git a/pkg/cli/compile_config.go b/pkg/cli/compile_config.go index c58c942b825..af119749110 100644 --- a/pkg/cli/compile_config.go +++ b/pkg/cli/compile_config.go @@ -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