From e7ca7cd08f7f6c47e9949265690d231489577178 Mon Sep 17 00:00:00 2001 From: moltenhub-bot Date: Fri, 10 Apr 2026 22:30:57 -0700 Subject: [PATCH] moltenhub-fallback-github-owner-on-repo-not-found --- internal/harness/harness.go | 210 +++++++++++++++++- internal/harness/harness_clone_errors_test.go | 41 ++++ internal/harness/harness_test.go | 85 +++++++ 3 files changed, 335 insertions(+), 1 deletion(-) diff --git a/internal/harness/harness.go b/internal/harness/harness.go index 7da1d452..8f311869 100644 --- a/internal/harness/harness.go +++ b/internal/harness/harness.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "os" "path/filepath" "regexp" @@ -168,6 +169,7 @@ func (h Harness) Run(ctx context.Context, cfg config.Config) Result { if len(repoURLs) == 0 { return h.fail(ExitConfig, "config", fmt.Errorf("one of repo, repoUrl, or repos[] is required"), runDir) } + repoOwnerHints := repoOwnerFallbackCandidates(repoURLs) runCfg := cfg cloneBaseBranch := strings.TrimSpace(runCfg.BaseBranch) if cloneBaseBranch == "" { @@ -175,7 +177,8 @@ func (h Harness) Run(ctx context.Context, cfg config.Config) Result { } repos := make([]repoWorkspace, 0, len(repoURLs)) - for i, repoURL := range repoURLs { + for i, requestedRepoURL := range repoURLs { + repoURL := requestedRepoURL relDir := repoWorkspaceDirName(repoURL, i, len(repoURLs)) repoDir := filepath.Join(runDir, relDir) branchForClone := cloneBaseBranch @@ -188,6 +191,52 @@ func (h Harness) Run(ctx context.Context, cfg config.Config) Result { relDir, cloneRepoCommand(repoURL, branchForClone, repoDir), ) + if cloneErr != nil && isRepoNotFoundCloneError(cloneErr, cloneRes) { + if fallbackRepoURL, ok := repoOwnerFallbackURL(repoURL, repoOwnerHints); ok { + h.logf( + "stage=clone status=warn action=fallback_repo_owner reason=repository_not_found repo=%s fallback_repo=%s branch=%s repo_dir=%s", + repoURL, + fallbackRepoURL, + branchForClone, + relDir, + ) + if err := os.RemoveAll(repoDir); err != nil { + return h.fail(ExitClone, "clone", fmt.Errorf("cleanup failed clone dir %s: %w", repoDir, err), runDir) + } + fallbackRes, fallbackErr := h.runCloneWithRetry( + ctx, + fallbackRepoURL, + branchForClone, + repoDir, + relDir, + cloneRepoCommand(fallbackRepoURL, branchForClone, repoDir), + ) + if fallbackErr == nil { + repoURL = fallbackRepoURL + cloneRes = fallbackRes + cloneErr = nil + h.logf( + "stage=clone status=ok action=fallback_repo_owner repo=%s fallback_repo=%s branch=%s repo_dir=%s", + requestedRepoURL, + fallbackRepoURL, + branchForClone, + relDir, + ) + } else { + repoURL = fallbackRepoURL + cloneRes = fallbackRes + cloneErr = fallbackErr + h.logf( + "stage=clone status=warn action=fallback_repo_owner reason=fallback_failed repo=%s fallback_repo=%s branch=%s repo_dir=%s err=%q", + requestedRepoURL, + fallbackRepoURL, + branchForClone, + relDir, + fallbackErr, + ) + } + } + } if cloneErr != nil { if !shouldFallbackCloneToDefaultBranch(branchForClone, cloneRes, cloneErr) { return h.fail(ExitClone, "clone", cloneErr, runDir) @@ -1388,6 +1437,165 @@ func isRepoNotFoundCloneError(err error, res execx.Result) bool { strings.Contains(text, "repository does not exist") } +var gitHubSCPLikeRepoPattern = regexp.MustCompile(`(?i)^((?:[^@:\s/]+@)?github\.com:)([^/\s]+)/([^/\s]+?)(\.git)?$`) + +type gitHubRepoRef struct { + owner string + name string + hasGitSuffix bool + scpPrefix string + urlStyle bool + urlValue url.URL +} + +func parseGitHubRepoRef(repoURL string) (gitHubRepoRef, bool) { + repoURL = strings.TrimSpace(repoURL) + if repoURL == "" { + return gitHubRepoRef{}, false + } + + if matches := gitHubSCPLikeRepoPattern.FindStringSubmatch(repoURL); len(matches) == 5 { + owner := strings.TrimSpace(matches[2]) + name := strings.TrimSpace(matches[3]) + if owner == "" || name == "" { + return gitHubRepoRef{}, false + } + return gitHubRepoRef{ + owner: owner, + name: name, + hasGitSuffix: strings.TrimSpace(matches[4]) != "", + scpPrefix: matches[1], + }, true + } + + parsed, err := url.Parse(repoURL) + if err != nil { + return gitHubRepoRef{}, false + } + if !strings.EqualFold(strings.TrimSpace(parsed.Hostname()), "github.com") { + return gitHubRepoRef{}, false + } + path := strings.Trim(parsed.Path, "/") + if path == "" { + return gitHubRepoRef{}, false + } + parts := strings.Split(path, "/") + if len(parts) < 2 { + return gitHubRepoRef{}, false + } + owner := strings.TrimSpace(parts[0]) + name := strings.TrimSpace(parts[1]) + if owner == "" || name == "" { + return gitHubRepoRef{}, false + } + hasGitSuffix := strings.HasSuffix(strings.ToLower(name), ".git") + name = strings.TrimSuffix(name, ".git") + if name == "" { + return gitHubRepoRef{}, false + } + return gitHubRepoRef{ + owner: owner, + name: name, + hasGitSuffix: hasGitSuffix, + urlStyle: true, + urlValue: *parsed, + }, true +} + +func (r gitHubRepoRef) withOwner(owner string) (string, bool) { + owner = strings.TrimSpace(owner) + if owner == "" || strings.EqualFold(owner, r.owner) { + return "", false + } + if strings.ContainsAny(owner, " \t\r\n") || strings.Contains(owner, "/") { + return "", false + } + + repoName := r.name + if r.hasGitSuffix { + repoName += ".git" + } + if r.urlStyle { + updated := r.urlValue + updated.Path = "/" + owner + "/" + repoName + updated.RawPath = "" + return updated.String(), true + } + if strings.TrimSpace(r.scpPrefix) == "" { + return "", false + } + return r.scpPrefix + owner + "/" + repoName, true +} + +func repoOwnerFallbackCandidates(repoURLs []string) []string { + if len(repoURLs) == 0 { + return nil + } + + owners := make([]string, 0, len(repoURLs)) + seen := make(map[string]struct{}, len(repoURLs)) + appendOwner := func(owner string) { + owner = strings.TrimSpace(owner) + if owner == "" { + return + } + key := strings.ToLower(owner) + if _, exists := seen[key]; exists { + return + } + seen[key] = struct{}{} + owners = append(owners, owner) + } + + for _, repoURL := range repoURLs { + ref, ok := parseGitHubRepoRef(repoURL) + if !ok { + continue + } + appendOwner(ref.owner) + } + return owners +} + +func repoOwnerFallbackURL(repoURL string, ownerHints []string) (string, bool) { + ref, ok := parseGitHubRepoRef(repoURL) + if !ok { + return "", false + } + + candidates := make([]string, 0, len(ownerHints)+1) + seen := make(map[string]struct{}, len(ownerHints)+2) + seen[strings.ToLower(strings.TrimSpace(ref.owner))] = struct{}{} + appendCandidate := func(owner string) { + owner = strings.TrimSpace(owner) + if owner == "" { + return + } + key := strings.ToLower(owner) + if _, exists := seen[key]; exists { + return + } + seen[key] = struct{}{} + candidates = append(candidates, owner) + } + + for _, owner := range ownerHints { + appendCandidate(owner) + } + if defaultRef, ok := parseGitHubRepoRef(config.DefaultRepositoryURL); ok && strings.EqualFold(defaultRef.name, ref.name) { + appendCandidate(defaultRef.owner) + } + + for _, owner := range candidates { + candidateURL, ok := ref.withOwner(owner) + if !ok { + continue + } + return candidateURL, true + } + return "", false +} + func shouldFallbackCloneToDefaultBranch(baseBranch string, res execx.Result, err error) bool { if err == nil { return false diff --git a/internal/harness/harness_clone_errors_test.go b/internal/harness/harness_clone_errors_test.go index 2a346a28..839b0bc4 100644 --- a/internal/harness/harness_clone_errors_test.go +++ b/internal/harness/harness_clone_errors_test.go @@ -139,3 +139,44 @@ func TestIsRepoNotFoundCloneError(t *testing.T) { }) } } + +func TestRepoOwnerFallbackURL(t *testing.T) { + t.Parallel() + + repoURL := "git@github.com:moltenbot000/moltenhub-code.git" + hints := repoOwnerFallbackCandidates([]string{ + "git@github.com:Molten-Bot/user-portal.git", + repoURL, + }) + + got, ok := repoOwnerFallbackURL(repoURL, hints) + if !ok { + t.Fatal("repoOwnerFallbackURL() ok = false, want true") + } + if got != "git@github.com:Molten-Bot/moltenhub-code.git" { + t.Fatalf("repoOwnerFallbackURL() = %q, want %q", got, "git@github.com:Molten-Bot/moltenhub-code.git") + } +} + +func TestRepoOwnerFallbackURLNoCandidate(t *testing.T) { + t.Parallel() + + repoURL := "git@github.com:acme/private-repo.git" + if got, ok := repoOwnerFallbackURL(repoURL, repoOwnerFallbackCandidates([]string{repoURL})); ok || got != "" { + t.Fatalf("repoOwnerFallbackURL() = (%q, %v), want (\"\", false)", got, ok) + } +} + +func TestParseGitHubRepoRefSupportsSSHAndHTTPS(t *testing.T) { + t.Parallel() + + if ref, ok := parseGitHubRepoRef("git@github.com:Molten-Bot/moltenhub-code.git"); !ok || ref.owner != "Molten-Bot" || ref.name != "moltenhub-code" { + t.Fatalf("parseGitHubRepoRef(scp) = (%+v, %v), want owner/name parsed", ref, ok) + } + if ref, ok := parseGitHubRepoRef("ssh://git@github.com/Molten-Bot/moltenhub-code.git"); !ok || ref.owner != "Molten-Bot" || ref.name != "moltenhub-code" { + t.Fatalf("parseGitHubRepoRef(ssh URL) = (%+v, %v), want owner/name parsed", ref, ok) + } + if ref, ok := parseGitHubRepoRef("https://github.com/Molten-Bot/moltenhub-code.git"); !ok || ref.owner != "Molten-Bot" || ref.name != "moltenhub-code" { + t.Fatalf("parseGitHubRepoRef(https URL) = (%+v, %v), want owner/name parsed", ref, ok) + } +} diff --git a/internal/harness/harness_test.go b/internal/harness/harness_test.go index 9305fede..2b035f49 100644 --- a/internal/harness/harness_test.go +++ b/internal/harness/harness_test.go @@ -1742,6 +1742,91 @@ func TestRunRepoNotFoundCloneFailsWithoutRetry(t *testing.T) { } } +func TestRunRepoNotFoundCloneFallsBackToKnownOwner(t *testing.T) { + t.Parallel() + + cfg := sampleConfig() + cfg.RepoURL = "" + cfg.Repo = "" + cfg.Repos = []string{ + "git@github.com:Molten-Bot/user-portal.git", + "git@github.com:moltenbot000/moltenhub-code.git", + } + cfg.TargetSubdir = "." + + now := time.Date(2026, 4, 2, 15, 4, 5, 0, time.UTC) + guid := "abcdef123456" + runDir := testRunDir(guid) + agentsPath := filepath.Join(runDir, "AGENTS.md") + branch := "moltenhub-build-api" + + repoRelA := repoWorkspaceDirName(cfg.Repos[0], 0, len(cfg.Repos)) + repoRelB := repoWorkspaceDirName(cfg.Repos[1], 1, len(cfg.Repos)) + repoDirA := filepath.Join(runDir, repoRelA) + repoDirB := filepath.Join(runDir, repoRelB) + fallbackRepoB := "git@github.com:Molten-Bot/moltenhub-code.git" + + codexPrompt := workspaceCodexPrompt(cfg.Prompt, cfg.TargetSubdir, []repoWorkspace{ + {URL: cfg.Repos[0], RelDir: repoRelA}, + {URL: fallbackRepoB, RelDir: repoRelB}, + }) + codexPrompt = withAgentsPrompt(codexPrompt, agentsPath) + + fake := &fakeRunner{t: t, exps: []expectedRun{ + {cmd: execx.Command{Name: "git", Args: []string{"--version"}}}, + {cmd: execx.Command{Name: "gh", Args: []string{"--version"}}}, + {cmd: execx.Command{Name: "codex", Args: []string{"--help"}}}, + {cmd: execx.Command{Name: "gh", Args: []string{"auth", "status"}}}, + {cmd: cloneRepoCommand(cfg.Repos[0], cfg.BaseBranch, repoDirA)}, + { + cmd: cloneRepoCommand(cfg.Repos[1], cfg.BaseBranch, repoDirB), + res: execx.Result{Stderr: "remote: Repository not found.\nfatal: repository not found\n"}, + err: errors.New("clone failed"), + }, + {cmd: cloneRepoCommand(fallbackRepoB, cfg.BaseBranch, repoDirB)}, + {cmd: branchCommand(repoDirA, branch)}, + {cmd: branchCommand(repoDirB, branch)}, + {cmd: pushDryRunCommand(repoDirA, branch)}, + {cmd: pushDryRunCommand(repoDirB, branch)}, + {cmd: codexCommandWithOptions(runDir, codexPrompt, codexRunOptions{SkipGitRepoCheck: true})}, + {cmd: statusCommand(repoDirA), res: execx.Result{Stdout: " M file-a.go\n"}}, + {cmd: statusCommand(repoDirB), res: execx.Result{Stdout: " M file-b.go\n"}}, + {cmd: addCommand(repoDirA)}, + {cmd: commitCommand(repoDirA, cfg.CommitMessage)}, + {cmd: pushCommand(repoDirA, branch)}, + {cmd: prCreateCommand(repoDirA, cfg, branch), res: execx.Result{Stdout: "https://github.com/Molten-Bot/user-portal/pull/10\n"}}, + {cmd: prChecksCommand(repoDirA, "https://github.com/Molten-Bot/user-portal/pull/10")}, + {cmd: addCommand(repoDirB)}, + {cmd: commitCommand(repoDirB, cfg.CommitMessage)}, + {cmd: pushCommand(repoDirB, branch)}, + {cmd: prCreateCommand(repoDirB, cfg, branch), res: execx.Result{Stdout: "https://github.com/Molten-Bot/moltenhub-code/pull/20\n"}}, + {cmd: prChecksCommand(repoDirB, "https://github.com/Molten-Bot/moltenhub-code/pull/20")}, + }} + + h := New(fake) + h.Now = func() time.Time { return now } + h.Workspace = testWorkspaceManager(guid) + h.TargetDirOK = func(path string) bool { return path == repoDirA } + h.Sleep = func(context.Context, time.Duration) error { return nil } + + res := h.Run(context.Background(), cfg) + if res.Err != nil { + t.Fatalf("Run() err = %v", res.Err) + } + if res.ExitCode != ExitSuccess { + t.Fatalf("ExitCode = %d, want %d", res.ExitCode, ExitSuccess) + } + if got, want := len(res.RepoResults), 2; got != want { + t.Fatalf("len(RepoResults) = %d, want %d", got, want) + } + if got, want := res.RepoResults[1].RepoURL, fallbackRepoB; got != want { + t.Fatalf("RepoResults[1].RepoURL = %q, want %q", got, want) + } + if len(fake.exps) != 0 { + t.Fatalf("unconsumed expectations: %d", len(fake.exps)) + } +} + func TestRunMissingNonMoltenhubBaseBranchFailsClone(t *testing.T) { t.Parallel()