package forgejo import ( "bytes" "encoding/json" "errors" "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 MigrationRepositories []string 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 createOrganizationRequest struct { Username string `json:"username"` } 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"` Config map[string]string `json:"config"` } 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 branchProtection struct { ID int64 `json:"id"` BranchName string `json:"branch_name"` RuleName string `json:"rule_name"` EnablePush bool `json:"enable_push"` EnablePushWhitelist bool `json:"enable_push_whitelist"` } type branchProtectionRequest struct { BranchName string `json:"branch_name"` EnablePush bool `json:"enable_push"` EnablePushWhitelist bool `json:"enable_push_whitelist"` } type APIError struct { StatusCode int Status string } type createTokenRequest struct { Name string `json:"name"` Scopes []string `json:"scopes"` } type accessToken struct { SHA1 string `json:"sha1"` } var copyGit = runGit 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}, } } // CreateRegistryToken creates the package-registry token that Forgejo returns once. func CreateRegistryToken(baseURL, username, password, otp, name string) (string, error) { return createRegistryToken(&http.Client{Timeout: 15 * time.Second}, baseURL, username, password, otp, name) } func createRegistryToken(client *http.Client, baseURL, username, password, otp, name string) (string, error) { return createToken(client, baseURL, username, password, otp, name, []string{"read:package", "write:package"}) } // CreateDeliveryStatusToken creates the dedicated token used only to publish // commit statuses and pull-request comments. func CreateDeliveryStatusToken(baseURL, username, password, otp string) (string, error) { return createDeliveryStatusToken(&http.Client{Timeout: 15 * time.Second}, baseURL, username, password, otp) } func createDeliveryStatusToken(client *http.Client, baseURL, username, password, otp string) (string, error) { return createToken(client, baseURL, username, password, otp, "maidn-delivery-status", []string{"write:issue", "write:repository"}) } func createToken(client *http.Client, baseURL, username, password, otp, name string, scopes []string) (string, error) { if strings.TrimSpace(baseURL) == "" || username == "" || password == "" || strings.TrimSpace(name) == "" { return "", fmt.Errorf("Forgejo base URL, username, password, and token name are required") } body, err := json.Marshal(createTokenRequest{Name: name, Scopes: scopes}) if err != nil { return "", err } endpoint := fmt.Sprintf("%s/api/v1/users/%s/tokens", strings.TrimRight(baseURL, "/"), url.PathEscape(username)) request, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return "", err } request.SetBasicAuth(username, password) request.Header.Set("Content-Type", "application/json") if otp != "" { request.Header.Set("X-Forgejo-OTP", otp) } response, err := client.Do(request) if err != nil { return "", err } defer response.Body.Close() if response.StatusCode != http.StatusCreated { return "", &APIError{StatusCode: response.StatusCode, Status: response.Status} } var token accessToken if err := json.NewDecoder(response.Body).Decode(&token); err != nil { return "", fmt.Errorf("parse Forgejo token response: %w", err) } if token.SHA1 == "" { return "", fmt.Errorf("Forgejo did not return a token") } return token.SHA1, nil } 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) } // EnsureOrganization creates the configured owner only when explicitly allowed. func (rm *RepoManager) EnsureOrganization(create bool) (bool, error) { exists, err := rm.organizationExists() if err != nil { return false, err } if exists { return false, nil } if !create { return false, errors.New("Forgejo organization does not exist; rerun with --create-organization") } body, err := json.Marshal(createOrganizationRequest{Username: rm.Owner}) if err != nil { return false, err } status, err := rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/orgs", rm.BaseURL), body) if err != nil { return false, err } if status == http.StatusCreated { return true, nil } if status == http.StatusConflict { exists, err = rm.organizationExists() if err == nil && exists { return false, nil } } return false, fmt.Errorf("unexpected Forgejo organization create status %d", status) } func (rm *RepoManager) organizationExists() (bool, error) { status, err := rm.apiRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/orgs/%s", rm.BaseURL, url.PathEscape(rm.Owner)), 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 organization lookup status %d", status) } } 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 { 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, rm.repositoryCreateURL(), 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) { return rm.ensureRepository(name, description, false) } // EnsureInitializedRepository creates a base branch without application content. func (rm *RepoManager) EnsureInitializedRepository(name, description string) (bool, error) { return rm.ensureRepository(name, description, true) } // EnsureRepositoryCopy creates an independent, user-owned copy of source. // Existing repositories are left untouched so user-managed catalog changes are never overwritten. func (rm *RepoManager) EnsureRepositoryCopy(name, description, source string) (bool, error) { sourceURL, err := catalogSourceURL(source) if err != nil { return false, err } exists, err := rm.repoExists(name) if err != nil { return false, err } if exists { return false, rm.ensureRepositoryCopyRef(name) } if err := rm.createRepositoryCopy(name, description); err != nil { return false, err } temporary, err := os.MkdirTemp("", "maidn-catalog-*") if err != nil { return false, err } defer os.RemoveAll(temporary) cleanupAskPass, environment, err := rm.gitEnvironment() if err != nil { return false, err } defer cleanupAskPass() if err := copyGit("", environment, "clone", "--mirror", sourceURL, temporary); err != nil { return false, err } if err := copyGit(temporary, environment, "push", "--mirror", CloneURL(rm.BaseURL, rm.Owner, name)); err != nil { return false, err } return true, rm.ensureRepositoryCopyRef(name) } func (rm *RepoManager) ensureRepositoryCopyRef(name string) error { cleanupAskPass, environment, err := rm.gitEnvironment() if err != nil { return err } defer cleanupAskPass() if err := copyGit("", environment, "ls-remote", "--exit-code", CloneURL(rm.BaseURL, rm.Owner, name), "refs/heads/"+rm.Branch); err != nil { return errors.New("Tekton catalog repository does not contain the configured catalog ref; refusing to use ambiguous state") } return nil } func (rm *RepoManager) createRepositoryCopy(name, description string) error { body, err := json.Marshal(createRepoRequest{Name: name, Description: description, Private: true, AutoInit: false, DefaultBranch: rm.Branch}) if err != nil { return err } status, err := rm.apiRequest(http.MethodPost, rm.repositoryCreateURL(), body) if err != nil { return err } if status != http.StatusCreated { return fmt.Errorf("Forgejo catalog repository creation returned status %d; refusing to copy into an ambiguous existing repository", status) } return nil } func (rm *RepoManager) repositoryCreateURL() string { if rm.Owner == rm.Username { return fmt.Sprintf("%s/api/v1/user/repos", rm.BaseURL) } return fmt.Sprintf("%s/api/v1/orgs/%s/repos", rm.BaseURL, rm.Owner) } func catalogSourceURL(source string) (string, error) { parsed, err := url.Parse(source) if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Trim(parsed.Path, "/") == "" { return "", fmt.Errorf("Tekton catalog source must be a credential-free HTTPS repository URL") } return parsed.String(), nil } func (rm *RepoManager) ensureRepository(name, description string, autoInit bool) (bool, error) { exists, err := rm.repoExists(name) if err != nil { return false, err } if exists { return false, nil } return true, rm.createRepo(name, description, autoInit) } 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() 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 } for _, repository := range rm.MigrationRepositories { if repository == repoName { return rm.createMigrationPullRequest(repoName, targetBranch) } } rm.MigrationRepositories = append(rm.MigrationRepositories, repoName) 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 { if head == "" || base == "" || head == base { return fmt.Errorf("Forgejo pull request head and base must be different non-empty branches") } 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 } // EnsurePullRequest creates one pull request or returns the one already open // for the exact head branch. It refuses duplicate or otherwise ambiguous state. func (rm *RepoManager) EnsurePullRequest(repo, title, head, base string) error { if head == "" || base == "" || head == base { return fmt.Errorf("Forgejo pull request head and base must be different non-empty branches") } open, err := rm.HasOpenPullRequest(repo, head) if err != nil { return err } if open { return nil } 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 { return fmt.Errorf("unexpected Forgejo pull request status %d", status) } return nil } // HasOpenPullRequest reports whether exactly one pull request is open for head. func (rm *RepoManager) HasOpenPullRequest(repo, head string) (bool, 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 false, err } if status != http.StatusOK { return false, fmt.Errorf("unexpected Forgejo pull request lookup status %d", status) } if len(pullRequests) > 1 { return false, fmt.Errorf("multiple open Forgejo pull requests exist for branch %q", head) } return len(pullRequests) == 1, 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() if err != nil { return err } defer cleanupAskPass() return runGit(dir, environment, "push", repoURL, sourceRef+":refs/heads/"+targetBranch) } // DeliveryBranch returns the dedicated branch that carries generated delivery content. func DeliveryBranch(appName, baseBranch string) (string, error) { branch := "maidn/delivery-" + appName if appName == "" || branch == baseBranch { return "", fmt.Errorf("delivery branch and configured base branch must differ") } return branch, nil } // PublishDeliveryBranch generates and commits delivery content in a temporary clone. func (rm *RepoManager) PublishDeliveryBranch(sourceDir, sourceBranch, repoURL, deliveryBranch string, generate func(string) error) (bool, error) { if sourceDir == "" || sourceBranch == "" || deliveryBranch == "" || deliveryBranch == rm.Branch { return false, fmt.Errorf("delivery source branch and dedicated delivery branch are required and must differ from the base branch") } hasBranch, err := rm.HasRemoteBranch(repoURL, deliveryBranch) if err != nil { return false, err } temporary, err := os.MkdirTemp("", "maidn-delivery-*") if err != nil { return false, err } defer os.RemoveAll(temporary) cleanupAskPass, environment, err := rm.gitEnvironment() if err != nil { return false, err } defer cleanupAskPass() if hasBranch { if err := runGit("", environment, "clone", "--branch", deliveryBranch, repoURL, temporary); err != nil { return false, err } } else if err := runGit("", os.Environ(), "clone", "--no-local", "--branch", sourceBranch, sourceDir, temporary); err != nil { return false, err } if err := runGit(temporary, environment, "checkout", "-B", deliveryBranch); err != nil { return false, err } if err := generate(temporary); err != nil { return false, err } if err := runGit(temporary, environment, "add", ".tekton"); err != nil { return false, err } changed, err := gitDiffQuiet(temporary, environment, "--cached") if err != nil { return false, err } if changed { for _, args := range [][]string{{"config", "user.name", "Maidn"}, {"config", "user.email", "maidn@free-maidn.com"}, {"commit", "-m", "feat: add Maidn delivery pipeline"}} { if err := runGit(temporary, environment, args...); err != nil { return false, err } } } if !changed { return false, nil } if err := rm.PushBranch(temporary, repoURL, deliveryBranch); err != nil { return false, err } return true, nil } // PublishRepositoryPullRequest applies a managed change on a dedicated branch. // An existing branch is accepted only when it has exactly one open pull request. func (rm *RepoManager) PublishRepositoryPullRequest(repo, title, branch, base string, change func(string) error) (bool, error) { if repo == "" || branch == "" || base == "" || branch == base { return false, errors.New("repository pull request requires distinct non-empty branches") } repoURL := CloneURL(rm.BaseURL, rm.Owner, repo) hasBranch, err := rm.HasRemoteBranch(repoURL, branch) if err != nil { return false, err } temporary, err := os.MkdirTemp("", "maidn-registration-*") if err != nil { return false, err } defer os.RemoveAll(temporary) cleanupAskPass, environment, err := rm.gitEnvironment() if err != nil { return false, err } defer cleanupAskPass() checkout := base if hasBranch { checkout = branch } if err := runGit("", environment, "clone", "--branch", checkout, repoURL, temporary); err != nil { return false, err } if !hasBranch { if err := runGit(temporary, environment, "checkout", "-B", branch, "origin/"+base); err != nil { return false, err } } if err := change(temporary); err != nil { return false, err } changed, err := commitAndPush(temporary, repo, branch, environment) if err != nil { return false, err } if !changed { if !hasBranch { return false, nil } open, err := rm.HasOpenPullRequest(repo, branch) if err != nil { return false, err } if !open { return false, nil } return true, rm.MergePullRequest(repo, branch) } if err := rm.EnsurePullRequest(repo, title, branch, base); err != nil { return false, err } if err := rm.MergePullRequest(repo, branch); err != nil { return false, err } return true, nil } func gitDiffQuiet(dir string, environment []string, args ...string) (bool, error) { command := exec.Command("git", append([]string{"diff", "--quiet"}, args...)...) command.Dir = dir command.Env = environment if err := command.Run(); err != nil { if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() == 1 { return true, nil } return false, err } return false, nil } 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 } // BranchRevision resolves branch to the checked-out commit that may be published. func BranchRevision(dir, branch string) (string, error) { command := exec.Command("git", "rev-parse", "--verify", branch+"^{commit}") command.Dir = dir revision, err := command.Output() if err != nil { return "", fmt.Errorf("resolve source branch %q: %w", branch, err) } if revision = bytes.TrimSpace(revision); len(revision) == 0 { return "", fmt.Errorf("source branch %q has no commit", branch) } return string(revision), nil } // RemoteBranchRevision returns the remote branch commit, or an empty string when absent. func (rm *RepoManager) RemoteBranchRevision(repoURL, branch string) (string, error) { cleanupAskPass, environment, err := rm.gitEnvironment() if err != nil { return "", err } defer cleanupAskPass() command := exec.Command("git", "ls-remote", "--refs", repoURL, "refs/heads/"+branch) command.Env = environment output, err := command.Output() if err != nil { return "", err } fields := strings.Fields(string(output)) if len(fields) == 0 { return "", nil } if len(fields) != 2 || fields[1] != "refs/heads/"+branch { return "", fmt.Errorf("unexpected remote ref response for branch %q", branch) } return fields[0], 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") } // EnsureCleanCheckout refuses to publish an ambiguous local worktree. func EnsureCleanCheckout(dir string) error { command := exec.Command("git", "status", "--porcelain=v1", "--untracked-files=all") command.Dir = dir status, err := command.Output() if err != nil { return fmt.Errorf("inspect application checkout: %w", err) } if len(status) != 0 { return errors.New("application checkout has uncommitted changes; commit or discard them before publishing delivery") } return nil } // CheckoutOrigin returns the credential-free origin identity used before publishing. func CheckoutOrigin(dir string) (string, error) { command := exec.Command("git", "remote", "get-url", "origin") command.Dir = dir output, err := command.Output() if err != nil { return "", fmt.Errorf("read application checkout origin: %w", err) } origin := strings.TrimSpace(string(output)) if origin == "" { return "", errors.New("application checkout origin is empty") } return origin, nil } 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 hookURL(existing) != 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 hookURL(existing hook) string { if existing.URL != "" { return existing.URL } return existing.Config["url"] } // TriggerWebhookTest asks Forgejo to deliver a test push for the managed hook. func (rm *RepoManager) TriggerWebhookTest(repo, webhookURL, branch string) error { if repo == "" || webhookURL == "" || branch == "" { return errors.New("Forgejo repository, webhook URL, and branch 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) } var matching []hook for _, candidate := range hooks { if hookURL(candidate) == webhookURL { matching = append(matching, candidate) } } if len(matching) != 1 { return fmt.Errorf("expected one Forgejo webhook for URL %q", webhookURL) } values := url.Values{"ref": {branch}} status, err = rm.apiRequest(http.MethodPost, fmt.Sprintf("%s/%d/tests?%s", endpoint, matching[0].ID, values.Encode()), nil) if err != nil { return err } if status != http.StatusNoContent { return fmt.Errorf("unexpected Forgejo webhook test status %d", status) } return nil } // EnsureProtectedBranch disables direct pushes to the configured production branch. func (rm *RepoManager) EnsureProtectedBranch(repo, branch string) error { if repo == "" || branch == "" { return errors.New("Forgejo repository and production branch are required") } endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/branch_protections", rm.BaseURL, rm.Owner, repo) var protections []branchProtection status, err := rm.apiJSONRequest(http.MethodGet, endpoint, nil, &protections) if err != nil { return err } if status != http.StatusOK { return fmt.Errorf("unexpected Forgejo branch protection lookup status %d", status) } var matching []branchProtection for _, protection := range protections { if protection.BranchName == branch || protection.RuleName == branch { matching = append(matching, protection) } } if len(matching) > 1 { return fmt.Errorf("multiple Forgejo branch protections match production branch %q", branch) } if len(matching) == 1 { if matching[0].EnablePush || matching[0].EnablePushWhitelist { return fmt.Errorf("Forgejo production branch %q permits direct pushes", branch) } return nil } body, err := json.Marshal(branchProtectionRequest{BranchName: branch}) if err != nil { return err } status, err = rm.apiRequest(http.MethodPost, endpoint, body) if err != nil { return err } if status != http.StatusCreated { return fmt.Errorf("unexpected Forgejo branch protection 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() (func(), []string, error) { return GitEnvironment(rm.Username, rm.Token) } // GitEnvironment returns a temporary Git askpass environment without persisting credentials. func GitEnvironment(username, token string) (func(), []string, error) { if 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="+username, "MAIDN_GIT_TOKEN="+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) }