525 lines
16 KiB
Go
525 lines
16 KiB
Go
package process
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestGitOperatorPush_PreservesExistingUpstream(t *testing.T) {
|
|
repoDir, cleanup := setupTestRepo(t)
|
|
defer cleanup()
|
|
|
|
log := newTestLogger(t)
|
|
|
|
runGit(t, repoDir, "checkout", "-b", "feature/pr-branch")
|
|
writeFile(t, repoDir, "feature.txt", "feature branch\n")
|
|
runGit(t, repoDir, "add", ".")
|
|
runGit(t, repoDir, "commit", "-m", "feature commit")
|
|
runGit(t, repoDir, "push", "-u", "origin", "feature/pr-branch")
|
|
|
|
runGit(t, repoDir, "checkout", "main")
|
|
worktreeBase := t.TempDir()
|
|
directDir := filepath.Join(worktreeBase, "wt-direct")
|
|
suffixedDir := filepath.Join(worktreeBase, "wt-sfx")
|
|
runGit(t, repoDir, "worktree", "add", directDir, "feature/pr-branch")
|
|
runGit(t, repoDir, "worktree", "add", "-b", "feature/pr-branch-sfx", suffixedDir, "origin/feature/pr-branch")
|
|
|
|
runGit(t, suffixedDir, "branch", "--set-upstream-to=origin/feature/pr-branch", "feature/pr-branch-sfx")
|
|
writeFile(t, suffixedDir, "feature.txt", "feature branch\nlocal change\n")
|
|
runGit(t, suffixedDir, "add", ".")
|
|
runGit(t, suffixedDir, "commit", "-m", "local change")
|
|
|
|
before := strings.TrimSpace(runGit(t, suffixedDir, "rev-parse", "--abbrev-ref", "@{upstream}"))
|
|
if before != "origin/feature/pr-branch" {
|
|
t.Fatalf("upstream before push = %q, want %q", before, "origin/feature/pr-branch")
|
|
}
|
|
|
|
gitOp := NewGitOperator(suffixedDir, log, nil)
|
|
result, err := gitOp.Push(context.Background(), false, false)
|
|
if err != nil {
|
|
t.Fatalf("Push returned error: %v", err)
|
|
}
|
|
if !result.Success {
|
|
t.Fatalf("Push failed: %+v", result)
|
|
}
|
|
|
|
after := strings.TrimSpace(runGit(t, suffixedDir, "rev-parse", "--abbrev-ref", "@{upstream}"))
|
|
if after != "origin/feature/pr-branch" {
|
|
t.Fatalf("upstream after push = %q, want %q", after, "origin/feature/pr-branch")
|
|
}
|
|
}
|
|
|
|
func TestParseCommitDiff_PathsWithSpaces(t *testing.T) {
|
|
repoDir, cleanup := setupTestRepo(t)
|
|
defer cleanup()
|
|
|
|
log := newTestLogger(t)
|
|
|
|
// Create a file with spaces in its path, commit it, then verify parseCommitDiff
|
|
// extracts the correct unquoted file path.
|
|
dir := filepath.Join(repoDir, "path with spaces")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("failed to create dir: %v", err)
|
|
}
|
|
writeFile(t, dir, "file.md", "hello world\n")
|
|
runGit(t, repoDir, "add", ".")
|
|
runGit(t, repoDir, "commit", "-m", "add spaced path")
|
|
sha := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD"))
|
|
|
|
gitOp := NewGitOperator(repoDir, log, nil)
|
|
result, err := gitOp.ShowCommit(context.Background(), sha)
|
|
if err != nil {
|
|
t.Fatalf("ShowCommit error: %v", err)
|
|
}
|
|
if !result.Success {
|
|
t.Fatalf("ShowCommit failed: %+v", result)
|
|
}
|
|
|
|
expectedPath := "path with spaces/file.md"
|
|
if _, exists := result.Files[expectedPath]; !exists {
|
|
keys := make([]string, 0, len(result.Files))
|
|
for k := range result.Files {
|
|
keys = append(keys, k)
|
|
}
|
|
t.Errorf("expected Files to contain key %q, got keys: %v", expectedPath, keys)
|
|
}
|
|
}
|
|
|
|
func TestGitOperatorCreatePR_UsesAzureCLIForAzureRepos(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("shell wrapper test is Unix-only")
|
|
}
|
|
|
|
repoDir, cleanup := setupTestRepo(t)
|
|
defer cleanup()
|
|
|
|
log := newTestLogger(t)
|
|
runGit(t, repoDir, "checkout", "-b", "feature/azure-pr")
|
|
writeFile(t, repoDir, "azure.txt", "azure repos\n")
|
|
runGit(t, repoDir, "add", ".")
|
|
runGit(t, repoDir, "commit", "-m", "add azure change")
|
|
|
|
realGit, err := exec.LookPath("git")
|
|
if err != nil {
|
|
t.Fatalf("find git: %v", err)
|
|
}
|
|
|
|
scriptDir := t.TempDir()
|
|
azArgsPath := filepath.Join(scriptDir, "az-args.txt")
|
|
writeExecutable(t, filepath.Join(scriptDir, "git"), fmt.Sprintf("#!/bin/sh\nif [ \"$1\" = \"remote\" ] && [ \"$2\" = \"get-url\" ] && [ \"$3\" = \"origin\" ]; then\n printf '%s\\n' 'git@ssh.dev.azure.com:v3/acme/platform/widgets'\n exit 0\nfi\nexec %q \"$@\"\n", "%s", realGit))
|
|
writeExecutable(t, filepath.Join(scriptDir, "az"), fmt.Sprintf(`#!/bin/sh
|
|
if [ "$1" = "extension" ] && [ "$2" = "show" ]; then
|
|
exit 0
|
|
fi
|
|
printf '%%s\n' "$@" > %q
|
|
printf 'WARNING: preview command group\n' >&2
|
|
printf 'Creating pull request...\n'
|
|
cat <<'EOF'
|
|
{"pullRequestId":42,"repository":{"remoteUrl":"https://dev.azure.com/acme/platform/_git/widgets"}}
|
|
EOF
|
|
`, azArgsPath))
|
|
t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
|
|
gitOp := NewGitOperator(repoDir, log, nil)
|
|
result, err := gitOp.CreatePR(context.Background(), "Add Azure support", "PR body", "origin/main", true)
|
|
if err != nil {
|
|
t.Fatalf("CreatePR returned error: %v", err)
|
|
}
|
|
if !result.Success {
|
|
t.Fatalf("CreatePR failed: %+v", result)
|
|
}
|
|
|
|
if got, want := result.PRURL, "https://dev.azure.com/acme/platform/_git/widgets/pullrequest/42"; got != want {
|
|
t.Fatalf("PRURL = %q, want %q", got, want)
|
|
}
|
|
|
|
gotArgs := readScriptArgs(t, azArgsPath)
|
|
wantArgs := []string{
|
|
"repos",
|
|
"pr",
|
|
"create",
|
|
"--organization",
|
|
"https://dev.azure.com/acme",
|
|
"--project",
|
|
"platform",
|
|
"--repository",
|
|
"widgets",
|
|
"--source-branch",
|
|
"feature/azure-pr",
|
|
"--target-branch",
|
|
"main",
|
|
"--title",
|
|
"Add Azure support",
|
|
"--description",
|
|
"PR body",
|
|
"--draft",
|
|
"true",
|
|
"-o",
|
|
"json",
|
|
}
|
|
if strings.Join(gotArgs, "\n") != strings.Join(wantArgs, "\n") {
|
|
t.Fatalf("az args mismatch\n got: %q\nwant: %q", gotArgs, wantArgs)
|
|
}
|
|
}
|
|
|
|
func writeExecutable(t *testing.T, path, content string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
|
|
t.Fatalf("write executable %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
func writeGitRemoteWrapper(t *testing.T, scriptDir, originURL string) {
|
|
t.Helper()
|
|
realGit, err := exec.LookPath("git")
|
|
if err != nil {
|
|
t.Fatalf("find git: %v", err)
|
|
}
|
|
writeExecutable(
|
|
t,
|
|
filepath.Join(scriptDir, "git"),
|
|
fmt.Sprintf("#!/bin/sh\nif [ \"$1\" = \"remote\" ] && [ \"$2\" = \"get-url\" ] && [ \"$3\" = \"origin\" ]; then\n printf '%%s\\n' %q\n exit 0\nfi\nexec %q \"$@\"\n", originURL, realGit),
|
|
)
|
|
}
|
|
|
|
func TestGitOperatorCreatePR_UnsupportedGitLabRemote(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("shell wrapper test is Unix-only")
|
|
}
|
|
|
|
repoDir, cleanup := setupTestRepo(t)
|
|
defer cleanup()
|
|
|
|
log := newTestLogger(t)
|
|
runGit(t, repoDir, "checkout", "-b", "feature/gitlab-pr")
|
|
writeFile(t, repoDir, "gitlab.txt", "gitlab\n")
|
|
runGit(t, repoDir, "add", ".")
|
|
runGit(t, repoDir, "commit", "-m", "add gitlab change")
|
|
|
|
scriptDir := t.TempDir()
|
|
writeGitRemoteWrapper(t, scriptDir, "git@gitlab.com:acme/widgets.git")
|
|
t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
|
|
gitOp := NewGitOperator(repoDir, log, nil)
|
|
result, err := gitOp.CreatePR(context.Background(), "Title", "Body", "main", false)
|
|
if err != nil {
|
|
t.Fatalf("CreatePR returned error: %v", err)
|
|
}
|
|
if result.Success {
|
|
t.Fatalf("CreatePR should fail for GitLab remote: %+v", result)
|
|
}
|
|
if !strings.Contains(result.Error, "unsupported git remote") {
|
|
t.Fatalf("unexpected error: %q", result.Error)
|
|
}
|
|
}
|
|
|
|
func TestGitOperatorCreatePR_AzureMissingCLI(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("shell wrapper test is Unix-only")
|
|
}
|
|
|
|
repoDir, cleanup := setupTestRepo(t)
|
|
defer cleanup()
|
|
|
|
log := newTestLogger(t)
|
|
runGit(t, repoDir, "checkout", "-b", "feature/azure-missing-cli")
|
|
writeFile(t, repoDir, "azure.txt", "azure\n")
|
|
runGit(t, repoDir, "add", ".")
|
|
runGit(t, repoDir, "commit", "-m", "add azure change")
|
|
|
|
scriptDir := t.TempDir()
|
|
writeGitRemoteWrapper(t, scriptDir, "git@ssh.dev.azure.com:v3/acme/platform/widgets")
|
|
// Isolate PATH so CI's preinstalled az is not visible to LookPath.
|
|
t.Setenv("PATH", scriptDir)
|
|
|
|
gitOp := NewGitOperator(repoDir, log, nil)
|
|
result, err := gitOp.CreatePR(context.Background(), "Title", "Body", "main", false)
|
|
if err != nil {
|
|
t.Fatalf("CreatePR returned error: %v", err)
|
|
}
|
|
if result.Success {
|
|
t.Fatalf("CreatePR should fail without az: %+v", result)
|
|
}
|
|
if result.Error != errAzureCLIMissing {
|
|
t.Fatalf("error = %q, want %q", result.Error, errAzureCLIMissing)
|
|
}
|
|
}
|
|
|
|
func TestGitOperatorCreatePR_AzureMissingExtension(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("shell wrapper test is Unix-only")
|
|
}
|
|
|
|
repoDir, cleanup := setupTestRepo(t)
|
|
defer cleanup()
|
|
|
|
log := newTestLogger(t)
|
|
runGit(t, repoDir, "checkout", "-b", "feature/azure-missing-ext")
|
|
writeFile(t, repoDir, "azure.txt", "azure\n")
|
|
runGit(t, repoDir, "add", ".")
|
|
runGit(t, repoDir, "commit", "-m", "add azure change")
|
|
|
|
scriptDir := t.TempDir()
|
|
writeGitRemoteWrapper(t, scriptDir, "git@ssh.dev.azure.com:v3/acme/platform/widgets")
|
|
writeExecutable(t, filepath.Join(scriptDir, "az"), "#!/bin/sh\nif [ \"$1\" = \"extension\" ] && [ \"$2\" = \"show\" ]; then exit 1; fi\nexit 0\n")
|
|
t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
|
|
gitOp := NewGitOperator(repoDir, log, nil)
|
|
result, err := gitOp.CreatePR(context.Background(), "Title", "Body", "main", false)
|
|
if err != nil {
|
|
t.Fatalf("CreatePR returned error: %v", err)
|
|
}
|
|
if result.Success {
|
|
t.Fatalf("CreatePR should fail without azure-devops extension: %+v", result)
|
|
}
|
|
if result.Error != errAzureDevOpsExtensionMissing {
|
|
t.Fatalf("error = %q, want %q", result.Error, errAzureDevOpsExtensionMissing)
|
|
}
|
|
}
|
|
|
|
func TestEnsureAzureDevOpsCLI(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("shell wrapper test is Unix-only")
|
|
}
|
|
|
|
scriptDir := t.TempDir()
|
|
writeExecutable(t, filepath.Join(scriptDir, "az"), "#!/bin/sh\nif [ \"$1\" = \"extension\" ] && [ \"$2\" = \"show\" ]; then exit 0; fi\nexit 1\n")
|
|
t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
|
|
if err := ensureAzureDevOpsCLI(context.Background()); err != nil {
|
|
t.Fatalf("ensureAzureDevOpsCLI() error = %v", err)
|
|
}
|
|
|
|
t.Setenv("PATH", "")
|
|
if err := ensureAzureDevOpsCLI(context.Background()); err == nil {
|
|
t.Fatal("expected error when az is missing")
|
|
} else if err.Error() != errAzureCLIMissing {
|
|
t.Fatalf("error = %q, want %q", err.Error(), errAzureCLIMissing)
|
|
}
|
|
}
|
|
|
|
func TestDetectPRProvider(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
remote string
|
|
want prProvider
|
|
}{
|
|
{name: "azure https", remote: "https://dev.azure.com/acme/platform/_git/widgets", want: prProviderAzureRepos},
|
|
{name: "azure ssh", remote: "git@ssh.dev.azure.com:v3/acme/platform/widgets", want: prProviderAzureRepos},
|
|
{
|
|
name: "azure ssh url scheme",
|
|
remote: "ssh://ssh.dev.azure.com:22/v3/acme/platform/widgets",
|
|
want: prProviderAzureRepos,
|
|
},
|
|
{name: "visualstudio", remote: "https://acme.visualstudio.com/platform/_git/widgets", want: prProviderAzureRepos},
|
|
{name: "github", remote: "https://github.com/acme/widgets.git", want: prProviderGitHub},
|
|
{
|
|
name: "github path must not match azure substring",
|
|
remote: "git@github.com:acme/dev.azure.com-docs.git",
|
|
want: prProviderGitHub,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := detectPRProvider(tt.remote); got != tt.want {
|
|
t.Fatalf("detectPRProvider(%q) = %q, want %q", tt.remote, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRemoteHostFromURL(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
remote string
|
|
want string
|
|
}{
|
|
{name: "https", remote: "https://dev.azure.com/acme/platform/_git/widgets", want: "dev.azure.com"},
|
|
{name: "scp", remote: "git@ssh.dev.azure.com:v3/acme/platform/widgets", want: "ssh.dev.azure.com"},
|
|
{
|
|
name: "ssh url scheme",
|
|
remote: "ssh://ssh.dev.azure.com:22/v3/acme/platform/widgets",
|
|
want: "ssh.dev.azure.com",
|
|
},
|
|
{name: "github", remote: "https://github.com/acme/widgets.git", want: "github.com"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := remoteHostFromURL(tt.remote); got != tt.want {
|
|
t.Fatalf("remoteHostFromURL(%q) = %q, want %q", tt.remote, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseAzurePRCreateResponse(t *testing.T) {
|
|
payload := `{"pullRequestId":42,"repository":{"remoteUrl":"https://dev.azure.com/acme/platform/_git/widgets"}}`
|
|
stdout := "Creating pull request...\n" + payload
|
|
|
|
response, err := parseAzurePRCreateResponse(stdout)
|
|
if err != nil {
|
|
t.Fatalf("parseAzurePRCreateResponse() error = %v", err)
|
|
}
|
|
if response.PullRequestID != 42 {
|
|
t.Fatalf("PullRequestID = %d, want 42", response.PullRequestID)
|
|
}
|
|
}
|
|
|
|
func TestParseAzurePRCreateResponse_multilineJSON(t *testing.T) {
|
|
stdout := "Creating pull request...\n{\n \"pullRequestId\": 9,\n \"repository\": {\n \"remoteUrl\": \"https://dev.azure.com/acme/platform/_git/widgets\"\n }\n}\n"
|
|
|
|
response, err := parseAzurePRCreateResponse(stdout)
|
|
if err != nil {
|
|
t.Fatalf("parseAzurePRCreateResponse() error = %v", err)
|
|
}
|
|
if response.PullRequestID != 9 {
|
|
t.Fatalf("PullRequestID = %d, want 9", response.PullRequestID)
|
|
}
|
|
}
|
|
|
|
func TestParseAzurePRCreateResponse_bracesInString(t *testing.T) {
|
|
// Braces inside JSON string values must not break line-based parsing.
|
|
stdout := "status line\n" + `{"pullRequestId":7,"repository":{"remoteUrl":"https://dev.azure.com/o/p/_git/r","note":"} not end"}}`
|
|
|
|
response, err := parseAzurePRCreateResponse(stdout)
|
|
if err != nil {
|
|
t.Fatalf("parseAzurePRCreateResponse() error = %v", err)
|
|
}
|
|
if response.PullRequestID != 7 {
|
|
t.Fatalf("PullRequestID = %d, want 7", response.PullRequestID)
|
|
}
|
|
}
|
|
|
|
func TestParseAzureRepoInfo(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
remote string
|
|
wantOrg string
|
|
wantProj string
|
|
wantRepo string
|
|
}{
|
|
{
|
|
name: "dev azure https",
|
|
remote: "https://dev.azure.com/acme/platform/_git/widgets.git",
|
|
wantOrg: "https://dev.azure.com/acme",
|
|
wantProj: "platform",
|
|
wantRepo: "widgets",
|
|
},
|
|
{
|
|
name: "visualstudio https",
|
|
remote: "https://acme.visualstudio.com/platform/_git/widgets",
|
|
wantOrg: "https://acme.visualstudio.com",
|
|
wantProj: "platform",
|
|
wantRepo: "widgets",
|
|
},
|
|
{
|
|
name: "azure ssh",
|
|
remote: "git@ssh.dev.azure.com:v3/acme/platform/widgets",
|
|
wantOrg: "https://dev.azure.com/acme",
|
|
wantProj: "platform",
|
|
wantRepo: "widgets",
|
|
},
|
|
{
|
|
name: "azure ssh url scheme",
|
|
remote: "ssh://ssh.dev.azure.com:22/v3/acme/platform/widgets",
|
|
wantOrg: "https://dev.azure.com/acme",
|
|
wantProj: "platform",
|
|
wantRepo: "widgets",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
info, err := parseAzureRepoInfo(tt.remote)
|
|
if err != nil {
|
|
t.Fatalf("parseAzureRepoInfo(%q) error = %v", tt.remote, err)
|
|
}
|
|
if info.OrganizationURL != tt.wantOrg || info.Project != tt.wantProj || info.Repository != tt.wantRepo {
|
|
t.Fatalf("parseAzureRepoInfo(%q) = %+v, want org=%q project=%q repo=%q", tt.remote, info, tt.wantOrg, tt.wantProj, tt.wantRepo)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSanitizeRepositoryArgs(t *testing.T) {
|
|
got := sanitizeRepositoryArgs([]string{
|
|
"pr",
|
|
"create",
|
|
"--title",
|
|
"real title",
|
|
"--body=secret body",
|
|
"--description",
|
|
"secret description",
|
|
"--head",
|
|
"feature/branch",
|
|
})
|
|
|
|
want := []string{
|
|
"pr",
|
|
"create",
|
|
"--title",
|
|
"[REDACTED]",
|
|
"--body=[REDACTED]",
|
|
"--description",
|
|
"[REDACTED]",
|
|
"--head",
|
|
"feature/branch",
|
|
}
|
|
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
|
t.Fatalf("sanitizeRepositoryArgs() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRedactRemoteURL(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
url string
|
|
want string
|
|
}{
|
|
{
|
|
name: "https credentials",
|
|
url: "https://token:x-oauth-basic@github.com/acme/widgets.git",
|
|
want: "https://github.com/acme/widgets.git",
|
|
},
|
|
{
|
|
name: "scp style",
|
|
url: "git@ssh.dev.azure.com:v3/acme/platform/widgets",
|
|
want: "ssh.dev.azure.com:v3/acme/platform/widgets",
|
|
},
|
|
{
|
|
name: "plain https",
|
|
url: "https://dev.azure.com/acme/platform/_git/widgets",
|
|
want: "https://dev.azure.com/acme/platform/_git/widgets",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := redactRemoteURL(tt.url); got != tt.want {
|
|
t.Fatalf("redactRemoteURL(%q) = %q, want %q", tt.url, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func readScriptArgs(t *testing.T, path string) []string {
|
|
t.Helper()
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read args file: %v", err)
|
|
}
|
|
trimmed := strings.TrimSpace(string(content))
|
|
if trimmed == "" {
|
|
return nil
|
|
}
|
|
return strings.Split(trimmed, "\n")
|
|
}
|