maidn-cli/internal/forgejo/repo.go

430 lines
13 KiB
Go

package forgejo
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
type RepoManager struct {
BaseURL string
Token string
Owner string
Username string
ManifestsRepoName string
FluxRepoName string
Branch string
MigrationBranch string
MigrationPending bool
HTTPClient *http.Client
}
type createRepoRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Private bool `json:"private"`
AutoInit bool `json:"auto_init"`
DefaultBranch string `json:"default_branch"`
}
type pullRequestRequest struct {
Title string `json:"title"`
Head string `json:"head"`
Base string `json:"base"`
}
type pullRequest struct {
Number int `json:"number"`
}
type mergePullRequestRequest struct {
Do string `json:"Do"`
}
type hook struct {
ID int64 `json:"id"`
URL string `json:"url"`
}
type hookRequest struct {
Type string `json:"type,omitempty"`
Active bool `json:"active"`
AuthorizationHeader string `json:"authorization_header"`
Config map[string]string `json:"config"`
Events []string `json:"events"`
}
type APIError struct {
StatusCode int
Status string
}
func (e *APIError) Error() string {
return fmt.Sprintf("forgejo returned %s", e.Status)
}
func NewRepoManager(baseURL, token, owner, username, manifestsRepo, fluxRepo, branch, migrationBranch string) *RepoManager {
return &RepoManager{
BaseURL: strings.TrimRight(baseURL, "/"),
Token: token,
Owner: owner,
Username: username,
ManifestsRepoName: manifestsRepo,
FluxRepoName: fluxRepo,
Branch: branch,
MigrationBranch: migrationBranch,
HTTPClient: &http.Client{Timeout: 15 * time.Second},
}
}
func (rm *RepoManager) InitializeAll(createRepo func(string) error, createFlux func(string) error) error {
if err := rm.ensureRepo(rm.ManifestsRepoName, "Centralized deployment manifests for Flux CD", createRepo); err != nil {
return err
}
return rm.ensureRepo(rm.FluxRepoName, "Flux CD cluster configurations", createFlux)
}
func (rm *RepoManager) ensureRepo(name, description string, createStructure func(string) error) error {
exists, err := rm.repoExists(name)
if err != nil {
return err
}
if !exists {
if err := rm.createRepo(name, description, true); err != nil {
return err
}
}
return rm.setupRepository(CloneURL(rm.BaseURL, rm.Owner, name), name, exists, createStructure)
}
func (rm *RepoManager) repoExists(name string) (bool, error) {
status, err := rm.apiRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/repos/%s/%s", rm.BaseURL, rm.Owner, name), nil)
if err != nil {
return false, err
}
switch status {
case http.StatusOK:
return true, nil
case http.StatusNotFound:
return false, nil
default:
return false, fmt.Errorf("unexpected Forgejo repository lookup status %d", status)
}
}
func (rm *RepoManager) createRepo(name, description string, autoInit bool) error {
createURL := fmt.Sprintf("%s/api/v1/orgs/%s/repos", rm.BaseURL, rm.Owner)
if rm.Owner == rm.Username {
createURL = fmt.Sprintf("%s/api/v1/user/repos", rm.BaseURL)
}
body, err := json.Marshal(createRepoRequest{Name: name, Description: description, Private: true, AutoInit: autoInit, DefaultBranch: rm.Branch})
if err != nil {
return err
}
status, err := rm.apiRequest(http.MethodPost, createURL, body)
if err != nil {
return err
}
if status != http.StatusCreated && status != http.StatusConflict {
return fmt.Errorf("unexpected Forgejo create repository status %d", status)
}
return nil
}
func (rm *RepoManager) EnsureRepository(name, description string) (bool, error) {
exists, err := rm.repoExists(name)
if err != nil {
return false, err
}
if exists {
return false, nil
}
return true, rm.createRepo(name, description, false)
}
func (rm *RepoManager) setupRepository(repoURL, repoName string, existing bool, createStructure func(string) error) error {
tempDir, err := os.MkdirTemp("", "repo-setup-*")
if err != nil {
return err
}
defer os.RemoveAll(tempDir)
cleanupAskPass, environment, err := rm.gitEnvironment(tempDir)
if err != nil {
return err
}
defer cleanupAskPass()
if err := runGit("", environment, "clone", "--branch", rm.Branch, repoURL, tempDir); err != nil {
return err
}
targetBranch := rm.Branch
if existing {
targetBranch = rm.MigrationBranch
if err := runGit(tempDir, environment, "checkout", "-B", targetBranch, "origin/"+rm.Branch); err != nil {
return err
}
}
if err := createStructure(tempDir); err != nil {
return err
}
changed, err := commitAndPush(tempDir, repoName, targetBranch, environment)
if err != nil || !changed || !existing {
return err
}
rm.MigrationPending = true
return rm.createMigrationPullRequest(repoName, targetBranch)
}
func (rm *RepoManager) createMigrationPullRequest(repo, branch string) error {
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
}
status, err := rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls", rm.BaseURL, rm.Owner, repo), body)
if err != nil {
return err
}
if status != http.StatusCreated && status != http.StatusUnprocessableEntity {
return fmt.Errorf("unexpected Forgejo pull request status %d", status)
}
return nil
}
func (rm *RepoManager) MergePullRequest(repo, head string) error {
values := url.Values{"state": {"open"}, "head": {head}}
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls?%s", rm.BaseURL, rm.Owner, repo, values.Encode())
var pullRequests []pullRequest
status, err := rm.apiJSONRequest(http.MethodGet, endpoint, nil, &pullRequests)
if err != nil {
return err
}
if status != http.StatusOK || len(pullRequests) != 1 {
return fmt.Errorf("expected one open Forgejo pull request for branch %q", head)
}
body, err := json.Marshal(mergePullRequestRequest{Do: "merge"})
if err != nil {
return err
}
status, err = rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/merge", rm.BaseURL, rm.Owner, repo, pullRequests[0].Number), body)
if err != nil {
return err
}
if status != http.StatusOK {
return fmt.Errorf("unexpected Forgejo pull request merge status %d", status)
}
return nil
}
func (rm *RepoManager) PushBranch(dir, repoURL, branch string) error {
return rm.PushRef(dir, repoURL, "HEAD", branch)
}
func (rm *RepoManager) PushRef(dir, repoURL, sourceRef, targetBranch string) error {
cleanupAskPass, environment, err := rm.gitEnvironment(dir)
if err != nil {
return err
}
defer cleanupAskPass()
return runGit(dir, environment, "push", repoURL, sourceRef+":refs/heads/"+targetBranch)
}
func (rm *RepoManager) HasRemoteBranch(repoURL, branch string) (bool, error) {
cleanupAskPass, environment, err := rm.gitEnvironment("")
if err != nil {
return false, err
}
defer cleanupAskPass()
command := exec.Command("git", "ls-remote", "--exit-code", repoURL, "refs/heads/"+branch)
command.Env = environment
if err := command.Run(); err != nil {
if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() == 2 {
return false, nil
}
return false, err
}
return true, nil
}
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")
}
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/hooks", rm.BaseURL, rm.Owner, repo)
var hooks []hook
status, err := rm.apiJSONRequest(http.MethodGet, endpoint, nil, &hooks)
if err != nil {
return err
}
if status != http.StatusOK {
return fmt.Errorf("unexpected Forgejo webhook lookup status %d", status)
}
createRequest := hookRequest{
Type: "forgejo",
Active: true,
AuthorizationHeader: authorization,
Config: map[string]string{"url": webhookURL, "content_type": "json"},
Events: []string{"push", "pull_request"},
}
for _, existing := range hooks {
if existing.URL != webhookURL {
continue
}
request, err := json.Marshal(hookRequest{Active: createRequest.Active, AuthorizationHeader: createRequest.AuthorizationHeader, Config: createRequest.Config, Events: createRequest.Events})
if err != nil {
return err
}
status, err = rm.apiRequest(http.MethodPatch, fmt.Sprintf("%s/%d", endpoint, existing.ID), request)
if err != nil {
return err
}
if status != http.StatusOK {
return fmt.Errorf("unexpected Forgejo webhook update status %d", status)
}
return nil
}
request, err := json.Marshal(createRequest)
if err != nil {
return err
}
status, err = rm.apiRequest(http.MethodPost, endpoint, request)
if err != nil {
return err
}
if status != http.StatusCreated {
return fmt.Errorf("unexpected Forgejo webhook create status %d", status)
}
return nil
}
func commitAndPush(tempDir, repoName, branch string, environment []string) (bool, error) {
for _, args := range [][]string{{"config", "user.name", "Maidn"}, {"config", "user.email", "maidn@free-maidn.com"}, {"add", "."}} {
if err := runGit(tempDir, environment, args...); err != nil {
return false, err
}
}
cmd := exec.Command("git", "status", "--porcelain")
cmd.Dir = tempDir
cmd.Env = environment
output, err := cmd.Output()
if err != nil {
return false, err
}
if len(output) == 0 {
return false, nil
}
if err := runGit(tempDir, environment, "commit", "-m", "feat: initialize repository structure for CI/CD"); err != nil {
return false, err
}
if err := runGit(tempDir, environment, "push", "origin", "HEAD:"+branch); err != nil {
return false, err
}
fmt.Printf("[SUCCESS] Changes pushed to %s successfully.\n", repoName)
return true, nil
}
func (rm *RepoManager) gitEnvironment(tempDir string) (func(), []string, error) {
if rm.Token == "" {
return func() {}, append(os.Environ(), "GIT_TERMINAL_PROMPT=0"), nil
}
askPassDir, err := os.MkdirTemp("", "maidn-askpass-*")
if err != nil {
return nil, nil, err
}
path := filepath.Join(askPassDir, "maidn-askpass")
content := "#!/bin/sh\ncase \"$1\" in *Username*) printf '%s\\n' \"$MAIDN_GIT_USERNAME\" ;; *) printf '%s\\n' \"$MAIDN_GIT_TOKEN\" ;; esac\n"
if runtime.GOOS == "windows" {
path += ".cmd"
content = "@echo off\r\necho %~1 | findstr /I Username >nul\r\nif not errorlevel 1 (echo %MAIDN_GIT_USERNAME%) else (echo %MAIDN_GIT_TOKEN%)\r\n"
}
if err := os.WriteFile(path, []byte(content), 0700); err != nil {
_ = os.RemoveAll(askPassDir)
return nil, nil, err
}
environment := append(os.Environ(), "GIT_ASKPASS="+path, "GIT_TERMINAL_PROMPT=0", "MAIDN_GIT_USERNAME="+rm.Username, "MAIDN_GIT_TOKEN="+rm.Token)
return func() { _ = os.RemoveAll(askPassDir) }, environment, nil
}
func runGit(dir string, environment []string, args ...string) error {
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = environment
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func (rm *RepoManager) apiRequest(method, endpoint string, body []byte) (int, error) {
return rm.apiJSONRequest(method, endpoint, body, nil)
}
func (rm *RepoManager) apiJSONRequest(method, endpoint string, body []byte, result any) (int, error) {
client := rm.HTTPClient
if client == nil {
client = &http.Client{Timeout: 15 * time.Second}
}
request, err := http.NewRequest(method, endpoint, bytes.NewReader(body))
if err != nil {
return 0, err
}
request.Header.Set("Authorization", "token "+rm.Token)
request.Header.Set("Content-Type", "application/json")
response, err := client.Do(request)
if err != nil {
return 0, err
}
defer response.Body.Close()
if response.StatusCode >= 500 || response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
return 0, &APIError{StatusCode: response.StatusCode, Status: response.Status}
}
if result != nil && response.StatusCode != http.StatusNoContent {
if err := json.NewDecoder(response.Body).Decode(result); err != nil {
return 0, err
}
}
return response.StatusCode, nil
}
func FluxSourceURL(baseURL, owner, repo string) string {
return fmt.Sprintf("%s/%s/%s.git", strings.TrimRight(baseURL, "/"), owner, repo)
}
func CloneURL(baseURL, owner, repo string) string {
return fmt.Sprintf("%s/%s/%s.git", strings.TrimRight(baseURL, "/"), owner, repo)
}