diff --git a/internal/harness/harness.go b/internal/harness/harness.go index 2b3dc7f4..4ad9dff1 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" @@ -754,12 +755,17 @@ func (h Harness) cloneRepositories(ctx context.Context, repos []repoWorkspace, b if len(repos) == 0 { return baseBranch, nil } + repoURLs := make([]string, 0, len(repos)) + for _, repo := range repos { + repoURLs = append(repoURLs, repo.URL) + } + repoOwnerHints := repoOwnerFallbackCandidates(repoURLs) usedFallback := make([]bool, len(repos)) cloneErrors := make([]error, len(repos)) cloneOne := func(index int) { - fellBack, err := h.cloneRepository(ctx, repos[index], baseBranch) + fellBack, err := h.cloneRepository(ctx, &repos[index], baseBranch, repoOwnerHints) usedFallback[index] = fellBack cloneErrors[index] = err } @@ -806,23 +812,77 @@ func (h Harness) cloneRepositories(ctx context.Context, repos []repoWorkspace, b return effectiveBaseBranch, nil } -func (h Harness) cloneRepository(ctx context.Context, repo repoWorkspace, branch string) (bool, error) { +func (h Harness) cloneRepository(ctx context.Context, repo *repoWorkspace, branch string, repoOwnerHints []string) (bool, error) { + if repo == nil { + return false, fmt.Errorf("repo workspace is required") + } + branch = strings.TrimSpace(branch) if branch == "" { branch = "main" } - h.logf("stage=clone status=start repo=%s branch=%s repo_dir=%s", repo.URL, branch, repo.RelDir) + repoURL := repo.URL + requestedRepoURL := repoURL + h.logf("stage=clone status=start repo=%s branch=%s repo_dir=%s", repoURL, branch, repo.RelDir) cloneRes, cloneErr := h.runCloneWithRetry( ctx, - repo.URL, + repoURL, branch, repo.Dir, repo.RelDir, - cloneRepoCommand(repo.URL, branch, repo.Dir), + cloneRepoCommand(repoURL, branch, repo.Dir), ) + 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, + branch, + repo.RelDir, + ) + if err := os.RemoveAll(repo.Dir); err != nil { + return false, fmt.Errorf("cleanup failed clone dir %s: %w", repo.Dir, err) + } + fallbackRes, fallbackErr := h.runCloneWithRetry( + ctx, + fallbackRepoURL, + branch, + repo.Dir, + repo.RelDir, + cloneRepoCommand(fallbackRepoURL, branch, repo.Dir), + ) + if fallbackErr == nil { + repo.URL = fallbackRepoURL + 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, + branch, + repo.RelDir, + ) + } else { + repo.URL = fallbackRepoURL + 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, + branch, + repo.RelDir, + fallbackErr, + ) + } + } + } if cloneErr == nil { - h.logf("stage=clone status=ok repo=%s repo_dir=%s", repo.URL, repo.RelDir) + h.logf("stage=clone status=ok repo=%s repo_dir=%s", repoURL, repo.RelDir) return false, nil } if !shouldFallbackCloneToDefaultBranch(branch, cloneRes, cloneErr) { @@ -831,7 +891,7 @@ func (h Harness) cloneRepository(ctx context.Context, repo repoWorkspace, branch h.logf( "stage=clone status=warn action=fallback_default_branch reason=missing_remote_branch repo=%s branch=%s repo_dir=%s", - repo.URL, + repoURL, branch, repo.RelDir, ) @@ -840,18 +900,18 @@ func (h Harness) cloneRepository(ctx context.Context, repo repoWorkspace, branch } if _, err := h.runCloneWithRetry( ctx, - repo.URL, + repoURL, "", repo.Dir, repo.RelDir, - cloneRepoDefaultBranchCommand(repo.URL, repo.Dir), + cloneRepoDefaultBranchCommand(repoURL, repo.Dir), ); err != nil { return false, err } h.logf( "stage=clone status=ok action=fallback_default_branch repo=%s repo_dir=%s resolved_branch=%s", - repo.URL, + repoURL, repo.RelDir, "main", ) @@ -1455,6 +1515,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 ba9f69e7..ac2672a7 100644 --- a/internal/harness/harness_test.go +++ b/internal/harness/harness_test.go @@ -193,7 +193,7 @@ func TestRunHappyPathCreatesPR(t *testing.T) { targetDir := filepath.Join(repoDir, cfg.TargetSubdir) branch := "moltenhub-build-api" - fake := &fakeRunner{t: t, exps: []expectedRun{ + fake := &fakeRunner{t: t, allowUnorderedClones: true, 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"}}}, @@ -255,7 +255,7 @@ func TestRunPRCreateAlreadyExistsReusesExistingPR(t *testing.T) { prURL, ) - fake := &fakeRunner{t: t, exps: []expectedRun{ + fake := &fakeRunner{t: t, allowUnorderedClones: true, 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"}}}, @@ -1855,6 +1855,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, allowUnorderedClones: true, 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()