checkin
This commit is contained in:
140
pkg/git/ops.go
Normal file
140
pkg/git/ops.go
Normal file
@ -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
|
||||||
|
}
|
140
pkg/github/client.go
Normal file
140
pkg/github/client.go
Normal file
@ -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")
|
||||||
|
}
|
0
pkg/logger/logger.go
Normal file
0
pkg/logger/logger.go
Normal file
203
pkg/workflow/generator.go
Normal file
203
pkg/workflow/generator.go
Normal file
@ -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
|
||||||
|
}
|
Reference in New Issue
Block a user