diff --git a/pkg/git/ops.go b/pkg/git/ops.go new file mode 100644 index 0000000..22885ba --- /dev/null +++ b/pkg/git/ops.go @@ -0,0 +1,140 @@ +// Package git provides Git-related operations and validation. +package git + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/go-i2p/go-gh-mirror/pkg/config" + "github.com/go-i2p/go-gh-mirror/pkg/logger" +) + +// Client provides Git repository validation and operations. +type Client struct { + log *logger.Logger + httpClient *http.Client +} + +// NewClient creates a new Git client. +func NewClient(log *logger.Logger) *Client { + return &Client{ + log: log, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// ValidateRepos checks if both repositories are accessible. +func (c *Client) ValidateRepos(ctx context.Context, cfg *config.Config) error { + // Validate primary repository URL + if err := c.validateRepoURL(ctx, cfg.PrimaryRepo); err != nil { + return fmt.Errorf("invalid primary repository URL: %w", err) + } + + // Validate GitHub repository URL format + if !strings.Contains(cfg.MirrorRepo, "github.com") { + return fmt.Errorf("mirror repository must be a GitHub repository URL") + } + + // Extract owner and repo from GitHub URL + owner, repo, err := parseGitHubURL(cfg.MirrorRepo) + if err != nil { + return fmt.Errorf("failed to parse GitHub repository URL: %w", err) + } + + c.log.Debug("Parsed GitHub repository", "owner", owner, "repo", repo) + return nil +} + +// validateRepoURL checks if a Git repository URL is accessible. +func (c *Client) validateRepoURL(ctx context.Context, repoURL string) error { + // For HTTP/HTTPS URLs, try to access the repository + if strings.HasPrefix(repoURL, "http://") || strings.HasPrefix(repoURL, "https://") { + // For GitHub URLs, we can check info/refs + if strings.Contains(repoURL, "github.com") { + checkURL := ensureGitExtension(repoURL) + "/info/refs?service=git-upload-pack" + return c.checkEndpoint(ctx, checkURL) + } + + // For other Git servers, just try a HEAD request on the base URL + return c.checkEndpoint(ctx, ensureGitExtension(repoURL)) + } + + // For SSH URLs, we can't easily validate, so just check the format + if strings.HasPrefix(repoURL, "git@") || strings.HasPrefix(repoURL, "ssh://") { + // Basic validation for SSH URLs + if !strings.Contains(repoURL, ":") && !strings.Contains(repoURL, "/") { + return fmt.Errorf("invalid SSH URL format") + } + c.log.Debug("SSH URL provided, cannot fully validate accessibility", "url", repoURL) + return nil + } + + return fmt.Errorf("unsupported repository URL scheme") +} + +// checkEndpoint makes a HEAD request to check if an endpoint is accessible. +func (c *Client) checkEndpoint(ctx context.Context, url string) error { + req, err := http.NewRequestWithContext(ctx, "HEAD", url, nil) + if err != nil { + return fmt.Errorf("failed to create HTTP request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to access repository: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return fmt.Errorf("repository returned error status: %s", resp.Status) + } + + return nil +} + +// parseGitHubURL extracts the owner and repository from a GitHub URL. +func parseGitHubURL(githubURL string) (string, string, error) { + // Clean the URL to ensure we have the correct format + cleanURL := ensureGitExtension(githubURL) + + // Parse the URL + parsedURL, err := url.Parse(cleanURL) + if err != nil { + return "", "", fmt.Errorf("invalid URL: %w", err) + } + + // Handle HTTP(S) URLs + if parsedURL.Scheme == "http" || parsedURL.Scheme == "https" { + pathParts := strings.Split(strings.TrimPrefix(parsedURL.Path, "/"), "/") + if len(pathParts) < 2 { + return "", "", fmt.Errorf("invalid GitHub repository path: %s", parsedURL.Path) + } + return pathParts[0], strings.TrimSuffix(pathParts[1], ".git"), nil + } + + // Handle SSH URLs + if strings.HasPrefix(githubURL, "git@github.com:") { + path := strings.TrimPrefix(githubURL, "git@github.com:") + parts := strings.Split(path, "/") + if len(parts) < 2 { + return "", "", fmt.Errorf("invalid GitHub SSH URL format") + } + return parts[0], strings.TrimSuffix(parts[1], ".git"), nil + } + + return "", "", fmt.Errorf("unsupported GitHub URL format") +} + +// ensureGitExtension ensures the URL ends with .git for Git operations. +func ensureGitExtension(repoURL string) string { + if !strings.HasSuffix(repoURL, ".git") { + return repoURL + ".git" + } + return repoURL +} diff --git a/pkg/github/client.go b/pkg/github/client.go new file mode 100644 index 0000000..2ca676b --- /dev/null +++ b/pkg/github/client.go @@ -0,0 +1,140 @@ +// Package github provides functionality for interacting with the GitHub API. +package github + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/google/go-github/v61/github" + "golang.org/x/oauth2" + + "github.com/go-i2p/go-gh-mirror/pkg/config" + "github.com/go-i2p/go-gh-mirror/pkg/logger" +) + +const ( + workflowPath = ".github/workflows/sync-mirror.yml" +) + +// Client provides GitHub API functionality. +type Client struct { + client *github.Client + log *logger.Logger + cfg *config.Config + owner string + repo string +} + +// NewClient creates a new GitHub API client. +func NewClient(ctx context.Context, cfg *config.Config, log *logger.Logger) (*Client, error) { + var httpClient *http.Client + + // Create authenticated client if token is available + if cfg.GithubToken != "" { + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: cfg.GithubToken}, + ) + httpClient = oauth2.NewClient(ctx, ts) + log.Debug("Created authenticated GitHub client") + } else { + httpClient = http.DefaultClient + log.Debug("Created unauthenticated GitHub client") + } + + // Create GitHub client + client := github.NewClient(httpClient) + + // Parse owner and repo from mirror URL + owner, repo, err := parseGitHubURL(cfg.MirrorRepo) + if err != nil { + return nil, fmt.Errorf("failed to parse GitHub repository URL: %w", err) + } + + return &Client{ + client: client, + log: log, + cfg: cfg, + owner: owner, + repo: repo, + }, nil +} + +// SetupWorkflow creates or updates the workflow file in the repository. +func (c *Client) SetupWorkflow(ctx context.Context, workflowContent string) error { + c.log.Info("Setting up workflow in repository", "owner", c.owner, "repo", c.repo, "path", workflowPath) + + // Check if the file already exists + fileContent, _, resp, err := c.client.Repositories.GetContents( + ctx, + c.owner, + c.repo, + workflowPath, + &github.RepositoryContentGetOptions{}, + ) + + // Create a commit message based on whether we're creating or updating + commitMsg := "Add repository sync workflow" + var sha *string + + if err == nil && resp.StatusCode == http.StatusOK && fileContent != nil { + // File exists, we'll update it + commitMsg = "Update repository sync workflow" + sha = fileContent.SHA + c.log.Debug("Updating existing workflow file", "sha", *sha) + } else if resp != nil && resp.StatusCode != http.StatusNotFound { + // Unexpected error + return fmt.Errorf("failed to check for existing workflow file: %w", err) + } + + // Create or update the file + _, _, err = c.client.Repositories.CreateFile( + ctx, + c.owner, + c.repo, + workflowPath, + &github.RepositoryContentFileOptions{ + Message: &commitMsg, + Content: []byte(workflowContent), + SHA: sha, + }, + ) + + if err != nil { + return fmt.Errorf("failed to create/update workflow file: %w", err) + } + + c.log.Info("Workflow file successfully created/updated") + return nil +} + +// parseGitHubURL extracts the owner and repository from a GitHub URL. +func parseGitHubURL(githubURL string) (string, string, error) { + // Handle HTTP(S) URLs + if strings.HasPrefix(githubURL, "http://") || strings.HasPrefix(githubURL, "https://") { + parsedURL, err := url.Parse(githubURL) + if err != nil { + return "", "", fmt.Errorf("invalid URL: %w", err) + } + + pathParts := strings.Split(strings.TrimPrefix(parsedURL.Path, "/"), "/") + if len(pathParts) < 2 { + return "", "", fmt.Errorf("invalid GitHub repository path: %s", parsedURL.Path) + } + return pathParts[0], strings.TrimSuffix(pathParts[1], ".git"), nil + } + + // Handle SSH URLs + if strings.HasPrefix(githubURL, "git@github.com:") { + path := strings.TrimPrefix(githubURL, "git@github.com:") + parts := strings.Split(path, "/") + if len(parts) < 2 { + return "", "", fmt.Errorf("invalid GitHub SSH URL format") + } + return parts[0], strings.TrimSuffix(parts[1], ".git"), nil + } + + return "", "", fmt.Errorf("unsupported GitHub URL format") +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..e69de29 diff --git a/pkg/workflow/generator.go b/pkg/workflow/generator.go new file mode 100644 index 0000000..84e5d5a --- /dev/null +++ b/pkg/workflow/generator.go @@ -0,0 +1,203 @@ +// Package workflow generates the GitHub Actions workflow file. +package workflow + +import ( + "bytes" + "fmt" + "text/template" + + "gopkg.in/yaml.v3" + + "github.com/go-i2p/go-gh-mirror/pkg/config" + "github.com/go-i2p/go-gh-mirror/pkg/logger" +) + +// Generator generates GitHub Actions workflow files. +type Generator struct { + cfg *config.Config + log *logger.Logger +} + +// WorkflowTemplate is the structure for the GitHub Actions workflow. +type WorkflowTemplate struct { + PrimaryRepo string + MirrorRepo string + PrimaryBranch string + MirrorBranch string + CronSchedule string + ForceSync bool +} + +// NewGenerator creates a new workflow generator. +func NewGenerator(cfg *config.Config, log *logger.Logger) *Generator { + return &Generator{ + cfg: cfg, + log: log, + } +} + +// Generate creates a GitHub Actions workflow YAML file. +func (g *Generator) Generate() (string, error) { + // Determine cron schedule based on sync interval + cronSchedule := getCronSchedule(g.cfg.SyncInterval) + g.log.Debug("Using cron schedule", "schedule", cronSchedule) + + // Prepare template data + data := WorkflowTemplate{ + PrimaryRepo: g.cfg.PrimaryRepo, + MirrorRepo: g.cfg.MirrorRepo, + PrimaryBranch: g.cfg.PrimaryBranch, + MirrorBranch: g.cfg.MirrorBranch, + CronSchedule: cronSchedule, + ForceSync: g.cfg.ForceSync, + } + + // Generate workflow file from template + workflowYAML, err := generateWorkflowYAML(data) + if err != nil { + return "", fmt.Errorf("failed to generate workflow YAML: %w", err) + } + + return workflowYAML, nil +} + +// getCronSchedule converts a sync interval to a cron schedule. +func getCronSchedule(interval string) string { + switch interval { + case "hourly": + return "0 * * * *" + case "daily": + return "0 0 * * *" + case "weekly": + return "0 0 * * 0" + default: + return "0 * * * *" // Default to hourly + } +} + +// generateWorkflowYAML creates the complete workflow YAML from the template. +func generateWorkflowYAML(data WorkflowTemplate) (string, error) { + // Create the workflow structure using maps to maintain comment ordering + workflow := map[string]interface{}{ + "name": "Sync Primary Repository to GitHub Mirror", + "on": map[string]interface{}{ + "schedule": []map[string]string{ + {"cron": data.CronSchedule}, + }, + "workflow_dispatch": map[string]interface{}{}, // Allow manual triggering + }, + "jobs": map[string]interface{}{ + "sync": map[string]interface{}{ + "runs-on": "ubuntu-latest", + "steps": []map[string]interface{}{ + { + "name": "Checkout GitHub Mirror", + "uses": "actions/checkout@v3", + "with": map[string]interface{}{ + "fetch-depth": 0, + }, + }, + { + "name": "Configure Git", + "run": "git config user.name 'GitHub Actions'\ngit config user.email 'actions@github.com'", + }, + { + "name": "Sync Primary Repository", + "run": generateSyncScript(data), + "env": map[string]string{ + "GITHUB_TOKEN": "${{ secrets.GITHUB_TOKEN }}", + }, + }, + }, + }, + }, + } + + // Convert workflow to YAML + var buf bytes.Buffer + yamlEncoder := yaml.NewEncoder(&buf) + yamlEncoder.SetIndent(2) + err := yamlEncoder.Encode(workflow) + if err != nil { + return "", fmt.Errorf("failed to encode workflow to YAML: %w", err) + } + + // Add comments to the generated YAML + result := addComments(buf.String()) + return result, nil +} + +// generateSyncScript creates the Git commands for syncing repositories. +func generateSyncScript(data WorkflowTemplate) string { + tmpl := `# Add the primary repository as a remote +git remote add primary {{.PrimaryRepo}} + +# Fetch the latest changes from the primary repository +git fetch primary + +# Check if the primary branch exists in the primary repository +if git ls-remote --heads primary {{.PrimaryBranch}} | grep -q {{.PrimaryBranch}}; then + echo "Primary branch {{.PrimaryBranch}} found in primary repository" +else + echo "Error: Primary branch {{.PrimaryBranch}} not found in primary repository" + exit 1 +fi + +# Check if we're already on the mirror branch +if git rev-parse --verify --quiet {{.MirrorBranch}}; then + git checkout {{.MirrorBranch}} +else + # Create the mirror branch if it doesn't exist + git checkout -b {{.MirrorBranch}} +fi + +{{if .ForceSync}} +# Force-apply all changes from primary, overriding any conflicts +echo "Performing force sync from primary/{{.PrimaryBranch}} to {{.MirrorBranch}}" +git reset --hard primary/{{.PrimaryBranch}} +{{else}} +# Attempt to merge changes from primary +echo "Attempting to merge changes from primary/{{.PrimaryBranch}} to {{.MirrorBranch}}" +if ! git merge primary/{{.PrimaryBranch}} --no-edit; then + # If merge fails, prefer the primary repository's changes + echo "Merge conflict detected, preferring primary repository's changes" + git checkout --theirs . + git add . + git commit -m "Merge primary repository, preferring primary changes in conflicts" +fi +{{end}} + +# Push changes back to the mirror repository +git push origin {{.MirrorBranch}}` + + t, err := template.New("sync").Parse(tmpl) + if err != nil { + return "echo 'Error generating sync script'" // Fallback + } + + var buf bytes.Buffer + err = t.Execute(&buf, data) + if err != nil { + return "echo 'Error generating sync script'" // Fallback + } + + return buf.String() +} + +// addComments adds explanatory comments to the YAML. +func addComments(yaml string) string { + header := `# GitHub Actions workflow file to sync an external repository to this GitHub mirror. +# This file was automatically generated by go-gh-mirror. +# +# The workflow does the following: +# - Runs on a scheduled basis (and can also be triggered manually) +# - Clones the GitHub mirror repository +# - Fetches changes from the primary external repository +# - Applies those changes to the mirror repository +# - Pushes the updated content back to the GitHub mirror +# +# Authentication is handled by the GITHUB_TOKEN secret provided by GitHub Actions. + +` + return header + yaml +}