Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 20 additions & 13 deletions cmd/status/release.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand DownExpand Up@@ -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
}
55 changes: 55 additions & 0 deletions cmd/status/release_test.go
Original file line numberDiff line numberDiff line change
@@ -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)
}
7 changes: 5 additions & 2 deletions docs/commands.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand Down
43 changes: 42 additions & 1 deletion pkg/gh/client.go
Original file line numberDiff line numberDiff line change
@@ -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"
Expand DownExpand Up@@ -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
}
31 changes: 31 additions & 0 deletions pkg/gh/client_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
}
}
}
83 changes: 49 additions & 34 deletions pkg/gitops/publish.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
)

Expand All@@ -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"
Expand DownExpand Up@@ -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) {
Expand Down
Loading