diff --git a/cmd/status/release.go b/cmd/status/release.go index 620418ac..bde5c21a 100644 --- a/cmd/status/release.go +++ b/cmd/status/release.go @@ -4,15 +4,20 @@ import ( "context" "fmt" "os" - "os/exec" "path/filepath" "sort" "strings" + ghclient "github.com/codefly-dev/cli/pkg/gh" "github.com/fatih/color" + "github.com/google/go-github/v89/github" "github.com/spf13/cobra" ) +// newGitHubClient is a seam so the chore-issue test can point the API client at +// a local server instead of api.github.com. +var newGitHubClient = ghclient.NewClient + var releaseCmd = &cobra.Command{ Use: "release", Short: "Check release status of all agents and core", @@ -261,18 +266,20 @@ func createAgentIssue(baseDir string, status AgentStatus) error { status.CoreVer, status.LatestCore, status.Delta, agentPath) - // Use gh to create issue - cmd := exec.CommandContext(context.Background(), - "gh", "issue", "create", - "--title", title, - "--body", body, - "--label", "chore", - "--label", "dependencies") - cmd.Dir = agentPath - - if err := cmd.Run(); err != nil { + ctx := context.Background() + owner, repo, err := ghclient.RepoAtDir(ctx, agentPath) + if err != nil { return err } - - return nil + client, err := newGitHubClient() + if err != nil { + return err + } + labels := []string{"chore", "dependencies"} + _, _, err = client.Issues.Create(ctx, owner, repo, &github.IssueRequest{ + Title: &title, + Body: &body, + Labels: &labels, + }) + return err } diff --git a/cmd/status/release_test.go b/cmd/status/release_test.go new file mode 100644 index 00000000..0a45c717 --- /dev/null +++ b/cmd/status/release_test.go @@ -0,0 +1,55 @@ +package status + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/google/go-github/v89/github" + "github.com/stretchr/testify/require" +) + +// TestCreateAgentIssuePostsToAPI covers the chore-issue migration: the "core +// behind" issue is opened via the API against the agent's origin repository, +// carrying the chore/dependencies labels. +func TestCreateAgentIssuePostsToAPI(t *testing.T) { + baseDir := t.TempDir() + agentPath := filepath.Join(baseDir, "web") + require.NoError(t, os.MkdirAll(agentPath, 0o755)) + for _, args := range [][]string{ + {"init", "--quiet"}, + {"remote", "add", "origin", "https://github.com/testowner/testrepo.git"}, + } { + require.NoError(t, exec.Command("git", append([]string{"-C", agentPath}, args...)...).Run()) + } + + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/repos/testowner/testrepo/issues", r.URL.Path) + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"number":1}`) + })) + defer srv.Close() + + original := newGitHubClient + newGitHubClient = func() (*github.Client, error) { + endpoint := srv.URL + "/" + return github.NewClient(github.WithURLs(&endpoint, &endpoint)) + } + t.Cleanup(func() { newGitHubClient = original }) + + require.NoError(t, createAgentIssue(baseDir, AgentStatus{ + Name: "web", CoreVer: "0.1.0", LatestCore: "0.3.0", Delta: 5, + })) + require.Contains(t, body["title"], "update core") + labels, ok := body["labels"].([]any) + require.True(t, ok, "issue must carry labels") + require.ElementsMatch(t, []any{"chore", "dependencies"}, labels) +} diff --git a/docs/commands.md b/docs/commands.md index bc19e50c..3bfa7ecb 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -261,8 +261,11 @@ approved, merged pull request and verifies the publication digest, the snapshot revision bound into every Application, exact service paths, project authority, cluster identity, sync, operation, and Healthy status before writing evidence under `.codefly/gitops/evidence/`. -Publishing requires configured Git commit signing and an authenticated `gh` -session; observation uses the active authenticated `argocd` context. Rollback +Publishing requires configured Git commit signing and a GitHub token — from +`GITHUB_TOKEN`/`GH_TOKEN`, or an authenticated `gh` session as a credential +fallback — for the pull-request API; observation reads the promotion pull +request's review decision through `gh` and uses the active authenticated +`argocd` context. Rollback refuses a target revision unless a prior Healthy reviewed evidence receipt links that revision. diff --git a/pkg/gh/client.go b/pkg/gh/client.go index 28b35b63..a14fb42d 100644 --- a/pkg/gh/client.go +++ b/pkg/gh/client.go @@ -1,9 +1,15 @@ // Package gh provides a shared authenticated go-github client and token // resolution for the CLI's platform (REST API) flows — agent-release -// publishing and version listing — so they resolve credentials one way. +// publishing, version listing, promotion pull requests, chore issues, and +// library repository creation — so they resolve credentials and owner/repo one +// way. Git *content* operations (clone/add/commit/tag/push) stay on the git +// binary; the sole git touchpoint here is RepoAtDir, which reads the origin +// remote's URL to derive the owner/repo an API call needs — a config read. package gh import ( + "context" + "fmt" "os" "os/exec" "strings" @@ -49,3 +55,38 @@ func Token() string { } return strings.TrimSpace(string(out)) } + +// RepoAtDir resolves the owner and repository name from the `origin` remote of +// the git working tree at dir — the same repository the `gh` CLI infers when it +// runs from that directory. Platform operations need the owner/repo pair +// explicitly because, unlike `gh`, the API client does not read it from the +// ambient git remote. +func RepoAtDir(ctx context.Context, dir string) (owner, repo string, err error) { + cmd := exec.CommandContext(ctx, "git", "-C", dir, "remote", "get-url", "origin") + out, err := cmd.Output() + if err != nil { + return "", "", fmt.Errorf("resolve origin remote in %s: %w", dir, err) + } + return ParseRemote(strings.TrimSpace(string(out))) +} + +// ParseRemote extracts the owner and repository name from a github.com remote +// URL in either HTTPS (https://github.com/owner/repo.git) or SSH +// (git@github.com:owner/repo.git) form. A remote on any other host is rejected: +// the derived owner/repo is only meaningful against api.github.com, so silently +// accepting a non-github.com host would send an API call to the wrong place. +func ParseRemote(remote string) (owner, repo string, err error) { + trimmed := strings.TrimSuffix(remote, ".git") + trimmed = strings.TrimSuffix(trimmed, "/") + index := strings.Index(trimmed, "github.com") + if index < 0 { + return "", "", fmt.Errorf("not a github.com remote: %q", remote) + } + trimmed = trimmed[index+len("github.com"):] + trimmed = strings.TrimLeft(trimmed, ":/") + segments := strings.Split(trimmed, "/") + if len(segments) < 2 || segments[len(segments)-2] == "" || segments[len(segments)-1] == "" { + return "", "", fmt.Errorf("cannot derive owner/repo from remote %q", remote) + } + return segments[len(segments)-2], segments[len(segments)-1], nil +} diff --git a/pkg/gh/client_test.go b/pkg/gh/client_test.go index 417ec995..d384714a 100644 --- a/pkg/gh/client_test.go +++ b/pkg/gh/client_test.go @@ -78,3 +78,34 @@ func TestTokenEmptyWithoutCredentials(t *testing.T) { t.Fatalf("Token() = %q, want empty when no credential source exists", got) } } + +func TestParseRemote(t *testing.T) { + for _, tc := range []struct { + remote string + owner, repo string + wantErr bool + }{ + {remote: "https://github.com/codefly-dev/cli.git", owner: "codefly-dev", repo: "cli"}, + {remote: "https://github.com/codefly-dev/cli", owner: "codefly-dev", repo: "cli"}, + {remote: "git@github.com:codefly-dev/cli.git", owner: "codefly-dev", repo: "cli"}, + {remote: "ssh://git@github.com/codefly-dev/cli.git", owner: "codefly-dev", repo: "cli"}, + {remote: "https://github.com/codefly-dev/cli/", owner: "codefly-dev", repo: "cli"}, + {remote: "not-a-remote", wantErr: true}, + {remote: "https://gitlab.com/codefly-dev/cli.git", wantErr: true}, + } { + owner, repo, err := ParseRemote(tc.remote) + if tc.wantErr { + if err == nil { + t.Errorf("ParseRemote(%q) = %q/%q, want error", tc.remote, owner, repo) + } + continue + } + if err != nil { + t.Errorf("ParseRemote(%q): %v", tc.remote, err) + continue + } + if owner != tc.owner || repo != tc.repo { + t.Errorf("ParseRemote(%q) = %q/%q, want %q/%q", tc.remote, owner, repo, tc.owner, tc.repo) + } + } +} diff --git a/pkg/gitops/publish.go b/pkg/gitops/publish.go index a6643246..ad204d45 100644 --- a/pkg/gitops/publish.go +++ b/pkg/gitops/publish.go @@ -16,9 +16,11 @@ import ( "strconv" "strings" + ghclient "github.com/codefly-dev/cli/pkg/gh" "github.com/codefly-dev/cli/pkg/internal/mutationauthority" "github.com/codefly-dev/cli/pkg/orchestration" "github.com/codefly-dev/core/resources" + "github.com/google/go-github/v89/github" "gopkg.in/yaml.v3" ) @@ -28,6 +30,10 @@ var ( pathComponentPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`) ) +// newGitHubClient is a seam so promotion pull-request tests can point the API +// client at a local server instead of api.github.com. +var newGitHubClient = ghclient.NewClient + const ( httpsScheme = "https" sshScheme = "ssh" @@ -1251,69 +1257,78 @@ func openOrUpdatePullRequest(ctx context.Context, prepared *preparedRepository, commit, ) } - output, err := command(ctx, "", "gh", "pr", "list", - "--repo", prepared.plan.RepositorySlug, "--head", prepared.plan.PromotionBranch, - "--base", prepared.plan.BaseBranch, "--state", "open", "--json", "number,url,headRefOid") + owner, repo, err := splitRepositorySlug(prepared.plan.RepositorySlug) if err != nil { - return "", 0, fmt.Errorf("inspect promotion pull request: %w", err) + return "", 0, err } - var existing []struct { - Number int `json:"number"` - URL string `json:"url"` - HeadRefOID string `json:"headRefOid"` + client, err := newGitHubClient() + if err != nil { + return "", 0, err } - if err := json.Unmarshal([]byte(output), &existing); err != nil { - return "", 0, fmt.Errorf("decode promotion pull request: %w", err) + existing, _, err := client.PullRequests.List(ctx, owner, repo, &github.PullRequestListOptions{ + Head: owner + ":" + prepared.plan.PromotionBranch, + Base: prepared.plan.BaseBranch, + State: "open", + }) + if err != nil { + return "", 0, fmt.Errorf("inspect promotion pull request: %w", err) } if len(existing) > 1 { return "", 0, fmt.Errorf("multiple open promotion pull requests target %s", prepared.plan.PromotionBranch) } if len(existing) == 1 { pr := existing[0] - if pr.HeadRefOID != commit { - return "", 0, fmt.Errorf("pull request head is %s, expected %s", pr.HeadRefOID, commit) + if pr.GetHead().GetSHA() != commit { + return "", 0, fmt.Errorf("pull request head is %s, expected %s", pr.GetHead().GetSHA(), commit) } - if _, err := command(ctx, "", "gh", "pr", "edit", strconv.Itoa(pr.Number), - "--repo", prepared.plan.RepositorySlug, "--title", title, "--body", body); err != nil { + if _, _, err = client.PullRequests.Edit(ctx, owner, repo, pr.GetNumber(), &github.PullRequest{ + Title: &title, Body: &body, + }); err != nil { return "", 0, fmt.Errorf("update promotion pull request: %w", err) } - return pr.URL, pr.Number, nil + return pr.GetHTMLURL(), pr.GetNumber(), nil } - url, err := command(ctx, "", "gh", "pr", "create", "--repo", prepared.plan.RepositorySlug, - "--base", prepared.plan.BaseBranch, "--head", prepared.plan.PromotionBranch, - "--title", title, "--body", body) + created, _, err := client.PullRequests.Create(ctx, owner, repo, &github.NewPullRequest{ + Base: &prepared.plan.BaseBranch, + Head: &prepared.plan.PromotionBranch, + Title: &title, + Body: &body, + }) if err != nil { return "", 0, fmt.Errorf("open promotion pull request: %w", err) } - return verifyPullRequest(ctx, prepared.plan.RepositorySlug, strings.TrimSpace(url), prepared.plan.BaseBranch, commit) + return verifyPullRequest(ctx, client, owner, repo, created.GetNumber(), prepared.plan.BaseBranch, commit) } func localReviewRef(promotionBranch, commit string) string { return "refs/codefly/reviews/" + strings.ReplaceAll(promotionBranch, "/", "-") + "/" + commit } -func verifyPullRequest(ctx context.Context, repository, pullRequest, baseBranch, commit string) (string, int, error) { - output, err := command(ctx, "", "gh", "pr", "view", pullRequest, "--repo", repository, - "--json", "number,url,headRefOid,baseRefName") +// splitRepositorySlug splits a validated "owner/repo" slug. RepositorySlug is +// produced by validateRepositoryURL, so it is always exactly two segments. +func splitRepositorySlug(slug string) (owner, repo string, err error) { + parts := strings.SplitN(slug, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("invalid repository slug %q", slug) + } + return parts[0], parts[1], nil +} + +// verifyPullRequest re-reads the created pull request and confirms it targets +// the expected base at the expected head commit — a read-back guard against a +// create that silently landed against the wrong ref. +func verifyPullRequest(ctx context.Context, client *github.Client, owner, repo string, number int, baseBranch, commit string) (string, int, error) { + pr, _, err := client.PullRequests.Get(ctx, owner, repo, number) if err != nil { return "", 0, fmt.Errorf("verify promotion pull request: %w", err) } - var response struct { - Number int `json:"number"` - URL string `json:"url"` - HeadRefOID string `json:"headRefOid"` - BaseRefName string `json:"baseRefName"` - } - if err := json.Unmarshal([]byte(output), &response); err != nil { - return "", 0, fmt.Errorf("decode verified promotion pull request: %w", err) - } - if response.HeadRefOID != commit || response.BaseRefName != baseBranch { + if pr.GetHead().GetSHA() != commit || pr.GetBase().GetRef() != baseBranch { return "", 0, fmt.Errorf( "promotion pull request targets %s at %s, expected %s at %s", - response.BaseRefName, response.HeadRefOID, baseBranch, commit, + pr.GetBase().GetRef(), pr.GetHead().GetSHA(), baseBranch, commit, ) } - return response.URL, response.Number, nil + return pr.GetHTMLURL(), pr.GetNumber(), nil } func resolveGitops(workspace *resources.Workspace, environment string, local bool) (*repositoryConfig, string, string, string, error) { diff --git a/pkg/gitops/qualification_k3d_test.go b/pkg/gitops/qualification_k3d_test.go index 06d90c4d..3f838ac2 100644 --- a/pkg/gitops/qualification_k3d_test.go +++ b/pkg/gitops/qualification_k3d_test.go @@ -3,6 +3,8 @@ package gitops import ( "context" "fmt" + "net/http" + "net/http/httptest" "os" "os/exec" "path/filepath" @@ -11,6 +13,7 @@ import ( "time" "github.com/codefly-dev/core/resources" + "github.com/google/go-github/v89/github" ) var mindShapedServices = []string{ @@ -75,27 +78,35 @@ gitops: if err := os.WriteFile(kubectl, []byte("#!/bin/sh\ntouch \"$CODEFLY_TEST_KUBECTL_CALLED\"\nexit 97\n"), 0o755); err != nil { t.Fatal(err) } - gh := filepath.Join(bin, "gh") - ghScript := `#!/bin/sh -set -eu -if [ "$1 $2" = "pr list" ]; then - printf '%s\n' '[]' - exit 0 -fi -if [ "$1 $2" = "pr create" ]; then - printf '%s\n' 'https://github.com/codefly-test/manifests/pull/1' - exit 0 -fi -if [ "$1 $2" = "pr view" ]; then - revision="$(git --git-dir "$CODEFLY_TEST_REMOTE" rev-parse refs/heads/codefly/promote-payments-aws)" - printf '{"number":1,"url":"https://github.com/codefly-test/manifests/pull/1","headRefOid":"%s","baseRefName":"main"}\n' "$revision" - exit 0 -fi -exit 2 -` - if err := os.WriteFile(gh, []byte(ghScript), 0o755); err != nil { - t.Fatal(err) + // The promotion pull-request flow is a GitHub platform operation on the + // go-github API. Serve the three REST endpoints it touches from a local + // server and point the client at it, so no request reaches api.github.com. + // The head SHA is read from the promotion branch of the local remote, so + // verifyPullRequest sees the commit Publish actually pushed. + promotionPR := func(w http.ResponseWriter) { + out, err := exec.Command("git", "--git-dir", remote, "rev-parse", "refs/heads/codefly/promote-payments-aws").Output() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + fmt.Fprintf(w, `{"number":1,"html_url":"https://github.com/codefly-test/manifests/pull/1","head":{"sha":"%s"},"base":{"ref":"main"}}`, + strings.TrimSpace(string(out))) } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/repos/codefly-test/manifests/pulls" { + fmt.Fprint(w, "[]") // no open promotion pull request yet + return + } + promotionPR(w) // create (POST .../pulls) and verify (GET .../pulls/1) + })) + defer server.Close() + originalNewClient := newGitHubClient + newGitHubClient = func() (*github.Client, error) { + endpoint := server.URL + "/" + return github.NewClient(github.WithURLs(&endpoint, &endpoint)) + } + t.Cleanup(func() { newGitHubClient = originalNewClient }) + t.Setenv("CODEFLY_TEST_KUBECTL_CALLED", kubectlCalled) t.Setenv("CODEFLY_TEST_REMOTE", remote) t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) diff --git a/pkg/librarystore/github.go b/pkg/librarystore/github.go index 3fa8c1fc..4a640ded 100644 --- a/pkg/librarystore/github.go +++ b/pkg/librarystore/github.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io/fs" + "net/http" "os" "os/exec" "path/filepath" @@ -14,6 +15,8 @@ import ( "strings" "github.com/Masterminds/semver" + ghclient "github.com/codefly-dev/cli/pkg/gh" + "github.com/google/go-github/v89/github" ) // gitDir is the directory git owns in a working tree. It is never part of @@ -30,6 +33,10 @@ type GitHubStore struct { // remoteFor resolves the git remote for a library export. Tests override it to // point at a local bare repository so publish/resolve exercise real git. remoteFor func(language Language, name string) string + // ensureRepo creates the target repository when it is missing so a first-ever + // publish does not fail on `git clone`. Tests override it to a no-op because + // they publish to a local bare repository that always exists. + ensureRepo func(ctx context.Context, language Language, name string) error // commitIdentity is the author used for release commits. commitName string commitEmail string @@ -41,9 +48,44 @@ func NewGitHubStore(owner string) *GitHubStore { s.remoteFor = func(language Language, name string) string { return fmt.Sprintf("https://github.com/%s/%s.git", s.Owner, repositoryName(language, name)) } + s.ensureRepo = s.createRepositoryIfMissing return s } +// newGitHubClient is a seam so repository-creation tests can point the API +// client at a local server instead of api.github.com. +var newGitHubClient = ghclient.NewClient + +// createRepositoryIfMissing creates the export's GitHub repository when it does +// not exist yet. Repository creation is a GitHub *platform* operation — the one +// step git cannot perform — so it uses the go-github API while all content +// publishing stays on git. It runs only after a clone fails, so the common +// republish-of-an-existing-repo path never touches the API. The `Get` still +// runs first here so that, when the repository already exists (the clone failed +// for some other reason), we do not attempt a doomed create and mask the real +// clone error. The repository is public so consumers can `go get` it without +// codefly credentials. +func (s *GitHubStore) createRepositoryIfMissing(ctx context.Context, language Language, name string) error { + client, err := newGitHubClient() + if err != nil { + return err + } + repo := repositoryName(language, name) + if _, resp, getErr := client.Repositories.Get(ctx, s.Owner, repo); getErr == nil { + return nil + } else if resp == nil || resp.StatusCode != http.StatusNotFound { + return fmt.Errorf("check library repository %s/%s: %w", s.Owner, repo, getErr) + } + private := false + if _, _, err := client.Repositories.Create(ctx, s.Owner, &github.Repository{ + Name: &repo, + Private: &private, + }); err != nil { + return fmt.Errorf("create library repository %s/%s: %w", s.Owner, repo, err) + } + return nil +} + // repositoryName is the per-export repository name, e.g. "authkit-go". func repositoryName(language Language, name string) string { return fmt.Sprintf("%s-%s", name, language) @@ -131,8 +173,26 @@ func (s *GitHubStore) Publish(ctx context.Context, artifactDir string, c Coordin } defer os.RemoveAll(work) + // Clone first: an existing library republish is pure git and must not depend + // on the GitHub API being reachable or the caller being API-authenticated. + // Only when the clone fails do we reach for the platform API to create the + // repository (the one step git cannot do) and retry once. A clone that fails + // for any other reason (auth, network) surfaces from the retry. if err = s.git(ctx, "", "clone", "--quiet", remote, work); err != nil { - return Published{}, fmt.Errorf("clone %s (create the library repository first if it does not exist yet): %w", remote, err) + if createErr := s.ensureRepo(ctx, c.Language, c.Name); createErr != nil { + return Published{}, createErr + } + // Wipe whatever the failed first clone left behind so the retry clones + // into a clean directory. + if err = os.RemoveAll(work); err != nil { + return Published{}, err + } + if err = os.MkdirAll(work, 0o755); err != nil { + return Published{}, err + } + if err = s.git(ctx, "", "clone", "--quiet", remote, work); err != nil { + return Published{}, fmt.Errorf("clone %s: %w", remote, err) + } } if s.tagExists(ctx, work, tag) { return Published{}, fmt.Errorf("librarystore: %s %s is already published (versions are immutable)", c.Name, tag) diff --git a/pkg/librarystore/github_api_test.go b/pkg/librarystore/github_api_test.go new file mode 100644 index 00000000..f550a9c4 --- /dev/null +++ b/pkg/librarystore/github_api_test.go @@ -0,0 +1,115 @@ +package librarystore + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os/exec" + "path/filepath" + "testing" + + "github.com/google/go-github/v89/github" + "github.com/stretchr/testify/require" +) + +// withTestClient points the package's go-github client seam at a local server. +func withTestClient(t *testing.T, baseURL string) { + t.Helper() + original := newGitHubClient + newGitHubClient = func() (*github.Client, error) { + endpoint := baseURL + "/" + return github.NewClient(github.WithURLs(&endpoint, &endpoint)) + } + t.Cleanup(func() { newGitHubClient = original }) +} + +// TestGitHubStorePublishDoesNotTouchAPIWhenRepoExists is the regression guard +// for the clone-first ordering: a republish of an existing repository is pure +// git and must never reach the GitHub API. The pre-check-before-clone shape +// this replaced called ensureRepo on every publish, so this test would fail +// there (t.Fatal fires). +func TestGitHubStorePublishDoesNotTouchAPIWhenRepoExists(t *testing.T) { + ctx := context.Background() + remote := bareRepo(t) + s := NewGitHubStore("codefly-dev") + s.remoteFor = func(Language, string) string { return remote } + s.ensureRepo = func(context.Context, Language, string) error { + t.Fatal("ensureRepo must not run when the repository already exists") + return nil + } + modulePath := goModulePath(remote) + _, err := s.Publish(ctx, goModule(t, modulePath, "package authkit\n\nconst V = 1\n"), + Coordinates{Language: LanguageGo, Name: "authkit", Version: "1.0.0"}) + require.NoError(t, err) +} + +// TestGitHubStorePublishCreatesMissingRepository proves the recovery path: +// the first clone fails (remote does not exist yet), ensureRepo creates it, +// and the retry clones into a clean directory and publishes. +func TestGitHubStorePublishCreatesMissingRepository(t *testing.T) { + ctx := context.Background() + remote := filepath.Join(t.TempDir(), "remote.git") // does not exist yet + s := NewGitHubStore("codefly-dev") + s.remoteFor = func(Language, string) string { return remote } + created := 0 + s.ensureRepo = func(context.Context, Language, string) error { + created++ + return exec.Command("git", "init", "--quiet", "--bare", remote).Run() + } + modulePath := goModulePath(remote) + published, err := s.Publish(ctx, goModule(t, modulePath, "package authkit\n\nconst V = 1\n"), + Coordinates{Language: LanguageGo, Name: "authkit", Version: "1.0.0"}) + require.NoError(t, err) + require.Equal(t, 1, created, "ensureRepo must run exactly once, only after the first clone fails") + require.Equal(t, modulePath, published.ImportPath) +} + +func TestCreateRepositoryIfMissing(t *testing.T) { + t.Run("existing repository is not recreated", func(t *testing.T) { + posted := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/repos/codefly-dev/authkit-go": + fmt.Fprint(w, `{"name":"authkit-go"}`) + case r.Method == http.MethodPost: + posted = true + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"name":"authkit-go"}`) + default: + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer srv.Close() + withTestClient(t, srv.URL) + + s := NewGitHubStore("codefly-dev") + require.NoError(t, s.createRepositoryIfMissing(context.Background(), LanguageGo, "authkit")) + require.False(t, posted, "a repository that already exists must not be created") + }) + + t.Run("missing repository is created public under the org", func(t *testing.T) { + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/repos/codefly-dev/authkit-go": + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"message":"Not Found"}`) + case r.Method == http.MethodPost && r.URL.Path == "/orgs/codefly-dev/repos": + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"name":"authkit-go"}`) + default: + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer srv.Close() + withTestClient(t, srv.URL) + + s := NewGitHubStore("codefly-dev") + require.NoError(t, s.createRepositoryIfMissing(context.Background(), LanguageGo, "authkit")) + require.Equal(t, "authkit-go", body["name"]) + require.Equal(t, false, body["private"]) + }) +} diff --git a/pkg/librarystore/github_test.go b/pkg/librarystore/github_test.go index ae1f129f..f3d5dcdb 100644 --- a/pkg/librarystore/github_test.go +++ b/pkg/librarystore/github_test.go @@ -29,6 +29,7 @@ func goModule(t *testing.T, modulePath, body string) string { func storeTo(remote string) *GitHubStore { s := NewGitHubStore("codefly-dev") s.remoteFor = func(Language, string) string { return remote } + s.ensureRepo = func(context.Context, Language, string) error { return nil } return s }