From a45618d7046b79b2ca97d2d7ba9128f3437e1157 Mon Sep 17 00:00:00 2001 From: eding Date: Sun, 26 Jul 2026 20:43:44 +0200 Subject: [PATCH] feat: publish app delivery branches --- cmd/bootstrap.go | 25 +++++++++++++++++++++ internal/forgejo/repo.go | 41 ++++++++++++++++++++++++++++++++++- internal/forgejo/repo_test.go | 7 ++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index 703cfcb..538a8f7 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -5,6 +5,7 @@ import ( "github.com/Pingu-Studio/MaidnCLI/internal/bootstrap" "github.com/Pingu-Studio/MaidnCLI/internal/config" + "github.com/Pingu-Studio/MaidnCLI/internal/forgejo" "github.com/Pingu-Studio/MaidnCLI/internal/openbao" "github.com/Pingu-Studio/MaidnCLI/internal/ui" "github.com/spf13/cobra" @@ -17,6 +18,7 @@ var bootstrapYes bool var bootstrapPromptDemocraticCSI bool var bootstrapPromptOperationalSecrets bool var bootstrapInitializeOpenBaoRecovery bool +var bootstrapPublishAppFrom string var bootstrapCmd = &cobra.Command{ Use: "bootstrap", @@ -33,11 +35,34 @@ func init() { bootstrapCmd.Flags().BoolVar(&bootstrapPromptDemocraticCSI, "prompt-democratic-csi", false, "Prompt for and save Democratic CSI settings in --config") bootstrapCmd.Flags().BoolVar(&bootstrapPromptOperationalSecrets, "prompt-operational-secrets", false, "Prompt for and encrypt operational secrets for --config") bootstrapCmd.Flags().BoolVar(&bootstrapInitializeOpenBaoRecovery, "initialize-openbao-recovery", false, "Create and save a separate OpenBao recovery age identity for --config") + bootstrapCmd.Flags().StringVar(&bootstrapPublishAppFrom, "publish-app-from", "", "Push this app checkout's current branch and create a Forgejo delivery PR") } func runBootstrap(cmd *cobra.Command, args []string) error { var cfg config.Config var err error + if bootstrapPublishAppFrom != "" { + if bootstrapConfigPath == "" { + return fmt.Errorf("--publish-app-from requires --config") + } + cfg, err = config.Load(bootstrapConfigPath) + if err != nil { + return err + } + branch, err := forgejo.CurrentBranch(bootstrapPublishAppFrom) + if err != nil { + return err + } + owner, repo, err := forgejo.RepositoryFromURL(cfg.Delivery.AppRepoURL) + if err != nil { + return err + } + manager := forgejo.NewRepoManager(cfg.Git.BaseURL, cfg.Git.Token, owner, cfg.Git.Username, "", "", cfg.Delivery.AppRepoRef, "") + if err := manager.PushBranch(bootstrapPublishAppFrom, cfg.Delivery.AppRepoURL, branch); err != nil { + return err + } + return manager.CreatePullRequest(repo, "feat: migrate delivery to Tekton", branch, cfg.Delivery.AppRepoRef) + } if bootstrapConfigPath != "" { if bootstrapPromptDemocraticCSI || bootstrapPromptOperationalSecrets || bootstrapInitializeOpenBaoRecovery { diff --git a/internal/forgejo/repo.go b/internal/forgejo/repo.go index 7dcc0ce..1c42d86 100644 --- a/internal/forgejo/repo.go +++ b/internal/forgejo/repo.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "os" "os/exec" "path/filepath" @@ -163,7 +164,11 @@ func (rm *RepoManager) setupRepository(repoURL, repoName string, existing bool, } func (rm *RepoManager) createMigrationPullRequest(repo, branch string) error { - body, err := json.Marshal(pullRequestRequest{Title: "feat: bootstrap Maidn CI/CD structure", Head: branch, Base: rm.Branch}) + return rm.CreatePullRequest(repo, "feat: bootstrap Maidn CI/CD structure", branch, rm.Branch) +} + +func (rm *RepoManager) CreatePullRequest(repo, title, head, base string) error { + body, err := json.Marshal(pullRequestRequest{Title: title, Head: head, Base: base}) if err != nil { return err } @@ -177,6 +182,40 @@ func (rm *RepoManager) createMigrationPullRequest(repo, branch string) error { return nil } +func (rm *RepoManager) PushBranch(dir, repoURL, branch string) error { + cleanupAskPass, environment, err := rm.gitEnvironment(dir) + if err != nil { + return err + } + defer cleanupAskPass() + return runGit(dir, environment, "push", repoURL, "HEAD:refs/heads/"+branch) +} + +func CurrentBranch(dir string) (string, error) { + command := exec.Command("git", "branch", "--show-current") + command.Dir = dir + branch, err := command.Output() + if err != nil { + return "", err + } + if value := strings.TrimSpace(string(branch)); value != "" { + return value, nil + } + return "", fmt.Errorf("source repository is in detached HEAD state") +} + +func RepositoryFromURL(repoURL string) (string, string, error) { + parsed, err := url.Parse(repoURL) + if err != nil { + return "", "", err + } + parts := strings.Split(strings.Trim(strings.TrimSuffix(parsed.Path, ".git"), "/"), "/") + if len(parts) < 2 || parts[len(parts)-2] == "" || parts[len(parts)-1] == "" { + return "", "", fmt.Errorf("invalid Forgejo repository URL") + } + return parts[len(parts)-2], parts[len(parts)-1], nil +} + func (rm *RepoManager) EnsureWebhook(repo, webhookURL, authorization string) error { if webhookURL == "" || authorization == "" { return fmt.Errorf("Forgejo webhook URL and authorization are required") diff --git a/internal/forgejo/repo_test.go b/internal/forgejo/repo_test.go index 9ec4a02..443bbf5 100644 --- a/internal/forgejo/repo_test.go +++ b/internal/forgejo/repo_test.go @@ -82,6 +82,13 @@ func TestEnsureWebhookCreatesMissingWebhook(t *testing.T) { } } +func TestRepositoryFromURL(t *testing.T) { + owner, repo, err := RepositoryFromURL("https://git.example.test/team/app.git") + if err != nil || owner != "team" || repo != "app" { + t.Fatalf("RepositoryFromURL() = (%q, %q, %v)", owner, repo, err) + } +} + func TestRepoExistsReturnsFalseOnNotFound(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(http.StatusNotFound)