993 lines
32 KiB
Go
993 lines
32 KiB
Go
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)
|
|
}
|