From b090235d49539ae559240eaae30e5cc2f828c218 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Nov 2025 22:17:04 +0000 Subject: [PATCH 1/3] Initial plan From 6467d2bc7aae4161e37224d040d09dd93be429f8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Nov 2025 22:33:21 +0000 Subject: [PATCH 2/3] Convert application from Node.js to Go Co-authored-by: veverkap <22348+veverkap@users.noreply.github.com> --- .github/workflows/test.yml | 13 +- .gitignore | 14 +- Dockerfile | 51 ++--- README.md | 23 ++- config/config.go | 175 ++++++++++++++++ config/config_test.go | 272 +++++++++++++++++++++++++ docker-entrypoint.sh | 2 +- gitea/client.go | 347 +++++++++++++++++++++++++++++++ github/repositories.go | 403 +++++++++++++++++++++++++++++++++++++ go.mod | 11 + go.sum | 12 ++ logger/logger.go | 103 ++++++++++ main.go | 251 +++++++++++++++++++++++ 13 files changed, 1631 insertions(+), 46 deletions(-) create mode 100644 config/config.go create mode 100644 config/config_test.go create mode 100644 gitea/client.go create mode 100644 github/repositories.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 logger/logger.go create mode 100644 main.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 37c7d5d..1a4d3f1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,13 +8,16 @@ on: jobs: build: - runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-go@v5 with: - node-version: lts/* - - uses: arduino/setup-task@v2 - - run: task world + go-version: '1.24' + - name: Install dependencies + run: go mod download + - name: Run tests + run: go test -v ./... + - name: Build + run: go build -v . diff --git a/.gitignore b/.gitignore index 1247ebc..dd97169 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,16 @@ node_modules/ .vscode/ .task/ -dist/ \ No newline at end of file +dist/ + +# Go build artifacts +mirror-to-gitea +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +go.work +go.work.sum \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index ad53934..f500009 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,57 +1,48 @@ # Build stage -FROM node:lts-alpine AS builder +FROM golang:1.24-alpine AS builder # Set working directory WORKDIR /app -# Copy package files -COPY package.json package-lock.json ./ +# Install ca-certificates for HTTPS requests +RUN apk --no-cache add ca-certificates git -# Install all dependencies (including dev dependencies needed for build) -RUN npm ci +# Copy go mod files +COPY go.mod go.sum ./ + +# Download dependencies +RUN go mod download # Copy source code -COPY --chown=node:node . . +COPY . . -# Build the application with minification -RUN npm run build -- --minify - -# Prune dependencies stage -FROM node:lts-alpine AS deps - -WORKDIR /app - -# Copy package files -COPY --from=builder /app/package.json /app/package-lock.json ./ - -# Install production dependencies only -RUN npm install --omit=dev --production && \ - # Remove unnecessary npm cache and temp files to reduce size - npm cache clean --force && \ - rm -rf /tmp/* /var/cache/apk/* +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o mirror-to-gitea . # Production stage -FROM node:lts-alpine AS production +FROM alpine:latest AS production # Add Docker Alpine packages and remove cache in the same layer RUN apk --no-cache add ca-certificates tini && \ rm -rf /var/cache/apk/* # Set non-root user for better security -USER node +RUN addgroup -g 1000 appuser && \ + adduser -D -u 1000 -G appuser appuser -# Set working directory owned by node user +USER appuser + +# Set working directory owned by appuser WORKDIR /app # Copy only the built application and entry point from builder -COPY --from=builder --chown=node:node /app/dist ./dist -COPY --from=builder --chown=node:node /app/docker-entrypoint.sh . - -# Copy only production node_modules -COPY --from=deps --chown=node:node /app/node_modules ./node_modules +COPY --from=builder --chown=appuser:appuser /app/mirror-to-gitea . +COPY --chown=appuser:appuser docker-entrypoint.sh . # Make entry point executable +USER root RUN chmod +x /app/docker-entrypoint.sh +USER appuser # Set environment to production to disable development features ENV NODE_ENV=production diff --git a/README.md b/README.md index 0bccbbd..ddf6a80 100644 --- a/README.md +++ b/README.md @@ -199,21 +199,26 @@ services: ### Prerequisites -- nodejs -- [task](https://taskfile.dev) -- docker +- Go 1.24 or later +- Docker (optional, for Docker builds) -### Execute verification +### Building ```sh -task world +go build -o mirror-to-gitea . +``` + +### Running Tests + +```sh +go test -v ./... ``` ### Running locally -Create `.secrets.rc` containing at least the following variables: +Set the following environment variables: -```rc +```sh export GITHUB_USERNAME='...' export GITHUB_TOKEN='...' export GITEA_URL='...' @@ -229,10 +234,10 @@ export MIRROR_ORGANIZATIONS='true' # export GITEA_ORG_VISIBILITY='public' ``` -Execute the script in foreground: +Execute the application in foreground: ```sh -task run-local +./mirror-to-gitea ``` ## Kudos diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..5beb20e --- /dev/null +++ b/config/config.go @@ -0,0 +1,175 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +type GitHubConfig struct { + Username string + Token string + SkipForks bool + PrivateRepositories bool + MirrorIssues bool + MirrorStarred bool + MirrorOrganizations bool + UseSpecificUser bool + SingleRepo string + IncludeOrgs []string + ExcludeOrgs []string + PreserveOrgStructure bool + SkipStarredIssues bool +} + +type GiteaConfig struct { + URL string + Token string + Organization string + Visibility string + StarredReposOrg string +} + +type Config struct { + GitHub GitHubConfig + Gitea GiteaConfig + DryRun bool + Delay int + Include []string + Exclude []string + SingleRun bool +} + +func readEnv(variable string) string { + return os.Getenv(variable) +} + +func mustReadEnv(variable string) (string, error) { + val := os.Getenv(variable) + if val == "" { + return "", fmt.Errorf("invalid configuration, please provide %s", variable) + } + return val, nil +} + +func readBoolean(variable string) bool { + val := os.Getenv(variable) + return val == "true" || val == "TRUE" || val == "1" +} + +func readInt(variable string, defaultValue int) int { + val := os.Getenv(variable) + if val == "" { + return defaultValue + } + intVal, err := strconv.Atoi(val) + if err != nil { + return defaultValue + } + return intVal +} + +func splitAndTrim(s string) []string { + if s == "" { + return []string{} + } + parts := strings.Split(s, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +func Load() (*Config, error) { + const defaultDelay = 3600 + const defaultInclude = "*" + const defaultExclude = "" + + githubUsername, err := mustReadEnv("GITHUB_USERNAME") + if err != nil { + return nil, err + } + + giteaURL, err := mustReadEnv("GITEA_URL") + if err != nil { + return nil, err + } + + giteaToken, err := mustReadEnv("GITEA_TOKEN") + if err != nil { + return nil, err + } + + githubToken := readEnv("GITHUB_TOKEN") + privateRepositories := readBoolean("MIRROR_PRIVATE_REPOSITORIES") + mirrorIssues := readBoolean("MIRROR_ISSUES") + mirrorStarred := readBoolean("MIRROR_STARRED") + mirrorOrganizations := readBoolean("MIRROR_ORGANIZATIONS") + singleRepo := readEnv("SINGLE_REPO") + + // Validate GitHub token requirements + if privateRepositories && githubToken == "" { + return nil, fmt.Errorf("invalid configuration, mirroring private repositories requires setting GITHUB_TOKEN") + } + + if (mirrorIssues || mirrorStarred || mirrorOrganizations || singleRepo != "") && githubToken == "" { + return nil, fmt.Errorf("invalid configuration, mirroring issues, starred repositories, organizations, or a single repo requires setting GITHUB_TOKEN") + } + + includeStr := readEnv("INCLUDE") + if includeStr == "" { + includeStr = defaultInclude + } + + excludeStr := readEnv("EXCLUDE") + if excludeStr == "" { + excludeStr = defaultExclude + } + + starredOrg := readEnv("GITEA_STARRED_ORGANIZATION") + if starredOrg == "" { + starredOrg = "github" + } + + visibility := readEnv("GITEA_ORG_VISIBILITY") + if visibility == "" { + visibility = "public" + } + + config := &Config{ + GitHub: GitHubConfig{ + Username: githubUsername, + Token: githubToken, + SkipForks: readBoolean("SKIP_FORKS"), + PrivateRepositories: privateRepositories, + MirrorIssues: mirrorIssues, + MirrorStarred: mirrorStarred, + MirrorOrganizations: mirrorOrganizations, + UseSpecificUser: readBoolean("USE_SPECIFIC_USER"), + SingleRepo: singleRepo, + IncludeOrgs: splitAndTrim(readEnv("INCLUDE_ORGS")), + ExcludeOrgs: splitAndTrim(readEnv("EXCLUDE_ORGS")), + PreserveOrgStructure: readBoolean("PRESERVE_ORG_STRUCTURE"), + SkipStarredIssues: readBoolean("SKIP_STARRED_ISSUES"), + }, + Gitea: GiteaConfig{ + URL: giteaURL, + Token: giteaToken, + Organization: readEnv("GITEA_ORGANIZATION"), + Visibility: visibility, + StarredReposOrg: starredOrg, + }, + DryRun: readBoolean("DRY_RUN"), + Delay: readInt("DELAY", defaultDelay), + Include: splitAndTrim(includeStr), + Exclude: splitAndTrim(excludeStr), + SingleRun: readBoolean("SINGLE_RUN"), + } + + return config, nil +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..a7929e1 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,272 @@ +package config + +import ( + "os" + "testing" +) + +func TestConfiguration(t *testing.T) { + // Clean up environment variables before each test + cleanup := func() { + vars := []string{ + "DELAY", "DRY_RUN", "GITEA_TOKEN", "GITEA_URL", + "GITHUB_TOKEN", "GITHUB_USERNAME", "MIRROR_PRIVATE_REPOSITORIES", + "SKIP_FORKS", "MIRROR_ISSUES", "MIRROR_STARRED", "MIRROR_ORGANIZATIONS", + "SINGLE_REPO", "GITEA_ORGANIZATION", "GITEA_ORG_VISIBILITY", + "GITEA_STARRED_ORGANIZATION", "INCLUDE_ORGS", "EXCLUDE_ORGS", + "PRESERVE_ORG_STRUCTURE", "SKIP_STARRED_ISSUES", "USE_SPECIFIC_USER", + "INCLUDE", "EXCLUDE", "SINGLE_RUN", + } + for _, v := range vars { + os.Unsetenv(v) + } + } + + provideMandatory := func() { + os.Setenv("GITHUB_USERNAME", "test-username") + os.Setenv("GITEA_URL", "https://gitea.url") + os.Setenv("GITEA_TOKEN", "secret-gitea-token") + } + + t.Run("reads configuration with default values", func(t *testing.T) { + cleanup() + provideMandatory() + + cfg, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.GitHub.Username != "test-username" { + t.Errorf("expected username 'test-username', got %s", cfg.GitHub.Username) + } + + if cfg.GitHub.Token != "" { + t.Errorf("expected empty token, got %s", cfg.GitHub.Token) + } + + if cfg.GitHub.SkipForks { + t.Error("expected SkipForks to be false") + } + + if cfg.Gitea.URL != "https://gitea.url" { + t.Errorf("expected URL 'https://gitea.url', got %s", cfg.Gitea.URL) + } + + if cfg.Gitea.Token != "secret-gitea-token" { + t.Errorf("expected token 'secret-gitea-token', got %s", cfg.Gitea.Token) + } + + if cfg.Delay != 3600 { + t.Errorf("expected delay 3600, got %d", cfg.Delay) + } + }) + + t.Run("requires gitea url", func(t *testing.T) { + cleanup() + provideMandatory() + os.Unsetenv("GITEA_URL") + + _, err := Load() + if err == nil { + t.Error("expected error, got nil") + } + }) + + t.Run("requires gitea token", func(t *testing.T) { + cleanup() + provideMandatory() + os.Unsetenv("GITEA_TOKEN") + + _, err := Load() + if err == nil { + t.Error("expected error, got nil") + } + }) + + t.Run("requires github username", func(t *testing.T) { + cleanup() + provideMandatory() + os.Unsetenv("GITHUB_USERNAME") + + _, err := Load() + if err == nil { + t.Error("expected error, got nil") + } + }) + + t.Run("reads github token", func(t *testing.T) { + cleanup() + provideMandatory() + os.Setenv("GITHUB_TOKEN", "test-github-token") + + cfg, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.GitHub.Token != "test-github-token" { + t.Errorf("expected token 'test-github-token', got %s", cfg.GitHub.Token) + } + }) + + t.Run("dry run flag treats true as true", func(t *testing.T) { + cleanup() + provideMandatory() + os.Setenv("DRY_RUN", "true") + + cfg, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !cfg.DryRun { + t.Error("expected DryRun to be true") + } + }) + + t.Run("dry run flag treats 1 as true", func(t *testing.T) { + cleanup() + provideMandatory() + os.Setenv("DRY_RUN", "1") + + cfg, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !cfg.DryRun { + t.Error("expected DryRun to be true") + } + }) + + t.Run("dry run flag treats missing as false", func(t *testing.T) { + cleanup() + provideMandatory() + + cfg, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.DryRun { + t.Error("expected DryRun to be false") + } + }) + + t.Run("skip forks flag treats true as true", func(t *testing.T) { + cleanup() + provideMandatory() + os.Setenv("SKIP_FORKS", "true") + + cfg, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !cfg.GitHub.SkipForks { + t.Error("expected SkipForks to be true") + } + }) + + t.Run("skip forks flag treats 1 as true", func(t *testing.T) { + cleanup() + provideMandatory() + os.Setenv("SKIP_FORKS", "1") + + cfg, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !cfg.GitHub.SkipForks { + t.Error("expected SkipForks to be true") + } + }) + + t.Run("skip forks flag treats missing as false", func(t *testing.T) { + cleanup() + provideMandatory() + + cfg, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.GitHub.SkipForks { + t.Error("expected SkipForks to be false") + } + }) + + t.Run("mirror private repositories flag treats true as true", func(t *testing.T) { + cleanup() + provideMandatory() + os.Setenv("GITHUB_TOKEN", "test-token") + os.Setenv("MIRROR_PRIVATE_REPOSITORIES", "true") + + cfg, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !cfg.GitHub.PrivateRepositories { + t.Error("expected PrivateRepositories to be true") + } + }) + + t.Run("mirror private repositories flag treats 1 as true", func(t *testing.T) { + cleanup() + provideMandatory() + os.Setenv("GITHUB_TOKEN", "test-token") + os.Setenv("MIRROR_PRIVATE_REPOSITORIES", "1") + + cfg, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !cfg.GitHub.PrivateRepositories { + t.Error("expected PrivateRepositories to be true") + } + }) + + t.Run("mirror private repositories flag treats missing as false", func(t *testing.T) { + cleanup() + provideMandatory() + + cfg, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.GitHub.PrivateRepositories { + t.Error("expected PrivateRepositories to be false") + } + }) + + t.Run("requires github token on private repository mirroring", func(t *testing.T) { + cleanup() + provideMandatory() + os.Setenv("MIRROR_PRIVATE_REPOSITORIES", "true") + + _, err := Load() + if err == nil { + t.Error("expected error, got nil") + } + }) + + t.Run("parses delay", func(t *testing.T) { + cleanup() + provideMandatory() + os.Setenv("DELAY", "1200") + + cfg, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.Delay != 1200 { + t.Errorf("expected delay 1200, got %d", cfg.Delay) + } + }) +} diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 0a2fb1c..cc459c0 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -8,7 +8,7 @@ DELAY="${DELAY:-3600}" while true do echo "Starting to create mirrors..." - node /app/dist/index.js + /app/mirror-to-gitea case $SINGLE_RUN in (TRUE | true | 1) break;; diff --git a/gitea/client.go b/gitea/client.go new file mode 100644 index 0000000..a7b45c1 --- /dev/null +++ b/gitea/client.go @@ -0,0 +1,347 @@ +package gitea + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "math/rand" + "net/http" + "time" + + "github.com/google/go-github/v66/github" + "github.com/jaedle/mirror-to-gitea/config" + ghrepo "github.com/jaedle/mirror-to-gitea/github" +) + +type Client struct { + baseURL string + token string + httpClient *http.Client +} + +type Target struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` // "user" or "organization" +} + +type User struct { + ID int64 `json:"id"` + Username string `json:"username"` +} + +type Organization struct { + ID int64 `json:"id"` + Username string `json:"username"` +} + +type MigrateRepoRequest struct { + AuthToken string `json:"auth_token,omitempty"` + CloneAddr string `json:"clone_addr"` + Mirror bool `json:"mirror"` + RepoName string `json:"repo_name"` + UID int64 `json:"uid"` + Private bool `json:"private"` +} + +type Issue struct { + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + Closed bool `json:"closed"` +} + +type Label struct { + Name string `json:"name"` + Color string `json:"color"` +} + +type IssueResponse struct { + Number int `json:"number"` +} + +func NewClient(cfg *config.GiteaConfig) *Client { + return &Client{ + baseURL: cfg.URL, + token: cfg.Token, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func (c *Client) doRequest(method, path string, body interface{}) ([]byte, int, error) { + var reqBody io.Reader + if body != nil { + jsonData, err := json.Marshal(body) + if err != nil { + return nil, 0, err + } + reqBody = bytes.NewBuffer(jsonData) + } + + url := c.baseURL + path + req, err := http.NewRequest(method, url, reqBody) + if err != nil { + return nil, 0, err + } + + req.Header.Set("Authorization", "token "+c.token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } + + return respBody, resp.StatusCode, nil +} + +func (c *Client) GetUser() (*Target, error) { + respBody, statusCode, err := c.doRequest("GET", "/api/v1/user", nil) + if err != nil { + return nil, err + } + + if statusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get user: status %d", statusCode) + } + + var user User + if err := json.Unmarshal(respBody, &user); err != nil { + return nil, err + } + + return &Target{ + ID: user.ID, + Name: user.Username, + Type: "user", + }, nil +} + +func (c *Client) GetOrganization(orgName string) (*Target, error) { + respBody, statusCode, err := c.doRequest("GET", "/api/v1/orgs/"+orgName, nil) + if err != nil { + return nil, err + } + + if statusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get organization %s: status %d", orgName, statusCode) + } + + var org Organization + if err := json.Unmarshal(respBody, &org); err != nil { + return nil, err + } + + return &Target{ + ID: org.ID, + Name: orgName, + Type: "organization", + }, nil +} + +func (c *Client) CreateOrganization(orgName, visibility string, dryRun bool) error { + if dryRun { + log.Printf("DRY RUN: Would create Gitea organization: %s (%s)", orgName, visibility) + return nil + } + + // Check if organization already exists + _, statusCode, _ := c.doRequest("GET", "/api/v1/orgs/"+orgName, nil) + if statusCode == http.StatusOK { + log.Printf("Organization %s already exists", orgName) + return nil + } + + // Create the organization + createReq := map[string]interface{}{ + "username": orgName, + "visibility": visibility, + } + + _, statusCode, err := c.doRequest("POST", "/api/v1/orgs", createReq) + if err != nil { + return err + } + + if statusCode == http.StatusCreated || statusCode == http.StatusUnprocessableEntity { + log.Printf("Created organization: %s", orgName) + return nil + } + + return fmt.Errorf("failed to create organization %s: status %d", orgName, statusCode) +} + +func (c *Client) IsRepositoryMirrored(repoName string, target *Target) (bool, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s", target.Name, repoName) + _, statusCode, _ := c.doRequest("GET", path, nil) + return statusCode == http.StatusOK, nil +} + +func (c *Client) MirrorRepository(repo *ghrepo.Repository, target *Target, githubToken string) error { + migrateReq := MigrateRepoRequest{ + AuthToken: githubToken, + CloneAddr: repo.URL, + Mirror: true, + RepoName: repo.Name, + UID: target.ID, + Private: repo.Private, + } + + _, statusCode, err := c.doRequest("POST", "/api/v1/repos/migrate", migrateReq) + if err != nil { + return err + } + + if statusCode != http.StatusCreated { + return fmt.Errorf("failed to mirror repository %s: status %d", repo.Name, statusCode) + } + + log.Printf("Successfully mirrored: %s", repo.Name) + return nil +} + +func (c *Client) StarRepository(repoName string, target *Target, dryRun bool) error { + if dryRun { + log.Printf("DRY RUN: Would star repository in Gitea: %s/%s", target.Name, repoName) + return nil + } + + path := fmt.Sprintf("/api/v1/user/starred/%s/%s", target.Name, repoName) + _, statusCode, err := c.doRequest("PUT", path, nil) + if err != nil { + return err + } + + if statusCode != http.StatusNoContent { + return fmt.Errorf("failed to star repository %s/%s: status %d", target.Name, repoName, statusCode) + } + + log.Printf("Successfully starred repository in Gitea: %s/%s", target.Name, repoName) + return nil +} + +func (c *Client) MirrorIssues(ctx context.Context, ghClient *github.Client, repo *ghrepo.Repository, target *Target, githubToken string, dryRun bool) error { + if !repo.HasIssues { + log.Printf("Repository %s doesn't have issues enabled. Skipping issues mirroring.", repo.Name) + return nil + } + + if dryRun { + log.Printf("DRY RUN: Would mirror issues for repository: %s", repo.Name) + return nil + } + + // Fetch issues from GitHub + issues, err := c.fetchGitHubIssues(ctx, ghClient, repo) + if err != nil { + return err + } + + log.Printf("Found %d issues for %s", len(issues), repo.Name) + + // Create issues one by one to maintain order + for _, issue := range issues { + if err := c.createGiteaIssue(issue, repo, target); err != nil { + log.Printf("Error creating issue '%s': %v", issue.GetTitle(), err) + } + } + + log.Printf("Completed mirroring issues for %s", repo.Name) + return nil +} + +func (c *Client) fetchGitHubIssues(ctx context.Context, ghClient *github.Client, repo *ghrepo.Repository) ([]*github.Issue, error) { + opt := &github.IssueListByRepoOptions{ + State: "all", + ListOptions: github.ListOptions{PerPage: 100}, + } + + var allIssues []*github.Issue + for { + issues, resp, err := ghClient.Issues.ListByRepo(ctx, repo.Owner, repo.Name, opt) + if err != nil { + return nil, fmt.Errorf("error fetching issues for %s/%s: %w", repo.Owner, repo.Name, err) + } + allIssues = append(allIssues, issues...) + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + + return allIssues, nil +} + +func (c *Client) createGiteaIssue(issue *github.Issue, repo *ghrepo.Repository, target *Target) error { + body := fmt.Sprintf("*Originally created by @%s on %s*\n\n%s", + issue.GetUser().GetLogin(), + issue.GetCreatedAt().Format("2006-01-02"), + issue.GetBody()) + + giteaIssue := Issue{ + Title: issue.GetTitle(), + Body: body, + State: issue.GetState(), + Closed: issue.GetState() == "closed", + } + + path := fmt.Sprintf("/api/v1/repos/%s/%s/issues", target.Name, repo.Name) + respBody, statusCode, err := c.doRequest("POST", path, giteaIssue) + if err != nil { + return err + } + + if statusCode != http.StatusCreated { + return fmt.Errorf("failed to create issue: status %d", statusCode) + } + + var issueResp IssueResponse + if err := json.Unmarshal(respBody, &issueResp); err != nil { + return err + } + + log.Printf("Created issue #%d: %s", issueResp.Number, issue.GetTitle()) + + // Add labels if the issue has any + if len(issue.Labels) > 0 { + for _, label := range issue.Labels { + c.addLabelToIssue(repo, target, issueResp.Number, label.GetName()) + } + } + + return nil +} + +func (c *Client) addLabelToIssue(repo *ghrepo.Repository, target *Target, issueNumber int, labelName string) { + // First try to create the label if it doesn't exist + labelPath := fmt.Sprintf("/api/v1/repos/%s/%s/labels", target.Name, repo.Name) + label := Label{ + Name: labelName, + Color: generateRandomColor(), + } + c.doRequest("POST", labelPath, label) + + // Then add the label to the issue + issueLabelPath := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", target.Name, repo.Name, issueNumber) + labelList := map[string][]string{ + "labels": {labelName}, + } + if _, statusCode, err := c.doRequest("POST", issueLabelPath, labelList); err != nil || statusCode != http.StatusOK { + log.Printf("Error adding label %s to issue: %v", labelName, err) + } +} + +func generateRandomColor() string { + return fmt.Sprintf("%06x", rand.Intn(0xFFFFFF)) +} diff --git a/github/repositories.go b/github/repositories.go new file mode 100644 index 0000000..64d2af9 --- /dev/null +++ b/github/repositories.go @@ -0,0 +1,403 @@ +package github + +import ( + "context" + "fmt" + "log" + "strings" + + "github.com/google/go-github/v66/github" + "golang.org/x/oauth2" +) + +type Repository struct { + Name string + URL string + Private bool + Fork bool + Owner string + FullName string + HasIssues bool + Organization string + Starred bool +} + +type FetchOptions struct { + Username string + PrivateRepositories bool + SkipForks bool + MirrorStarred bool + MirrorOrganizations bool + SingleRepo string + IncludeOrgs []string + ExcludeOrgs []string + PreserveOrgStructure bool + UseSpecificUser bool +} + +func NewClient(token string) *github.Client { + if token == "" { + return github.NewClient(nil) + } + + ctx := context.Background() + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: token}, + ) + tc := oauth2.NewClient(ctx, ts) + return github.NewClient(tc) +} + +func GetRepositories(ctx context.Context, client *github.Client, opts FetchOptions) ([]*Repository, error) { + var repositories []*Repository + + // Check if we're mirroring a single repo + if opts.SingleRepo != "" { + repo, err := fetchSingleRepository(ctx, client, opts.SingleRepo) + if err != nil { + return nil, err + } + if repo != nil { + repositories = append(repositories, repo) + } + } else { + // Standard mirroring logic + publicRepos, err := fetchPublicRepositories(ctx, client, opts.Username) + if err != nil { + return nil, fmt.Errorf("failed to fetch public repositories: %w", err) + } + repositories = append(repositories, publicRepos...) + + if opts.PrivateRepositories { + privateRepos, err := fetchPrivateRepositories(ctx, client) + if err != nil { + return nil, fmt.Errorf("failed to fetch private repositories: %w", err) + } + repositories = append(repositories, privateRepos...) + } + + if opts.MirrorStarred { + var username string + if opts.UseSpecificUser { + username = opts.Username + } + starredRepos, err := fetchStarredRepositories(ctx, client, username) + if err != nil { + return nil, fmt.Errorf("failed to fetch starred repositories: %w", err) + } + repositories = append(repositories, starredRepos...) + } + + if opts.MirrorOrganizations { + var username string + if opts.UseSpecificUser { + username = opts.Username + } + orgRepos, err := fetchOrganizationRepositories(ctx, client, username, opts.IncludeOrgs, opts.ExcludeOrgs, opts.PreserveOrgStructure, opts.PrivateRepositories) + if err != nil { + return nil, fmt.Errorf("failed to fetch organization repositories: %w", err) + } + repositories = append(repositories, orgRepos...) + } + + // Filter duplicates + repositories = filterDuplicates(repositories) + } + + if opts.SkipForks { + repositories = withoutForks(repositories) + } + + return repositories, nil +} + +func fetchSingleRepository(ctx context.Context, client *github.Client, repoURL string) (*Repository, error) { + // Remove URL prefix if present and clean up + repoPath := repoURL + repoPath = strings.TrimPrefix(repoPath, "https://github.com/") + repoPath = strings.TrimSuffix(repoPath, ".git") + + // Split into owner and repo + parts := strings.Split(repoPath, "/") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid repository URL format: %s", repoURL) + } + + owner, repoName := parts[0], parts[1] + + repo, _, err := client.Repositories.Get(ctx, owner, repoName) + if err != nil { + return nil, fmt.Errorf("error fetching single repository %s: %w", repoURL, err) + } + + return toRepository(repo, false), nil +} + +func fetchPublicRepositories(ctx context.Context, client *github.Client, username string) ([]*Repository, error) { + opt := &github.RepositoryListOptions{ + ListOptions: github.ListOptions{PerPage: 100}, + } + + var allRepos []*github.Repository + for { + repos, resp, err := client.Repositories.List(ctx, username, opt) + if err != nil { + return nil, err + } + allRepos = append(allRepos, repos...) + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + + return toRepositoryList(allRepos, false), nil +} + +func fetchPrivateRepositories(ctx context.Context, client *github.Client) ([]*Repository, error) { + opt := &github.RepositoryListOptions{ + Affiliation: "owner", + Visibility: "private", + ListOptions: github.ListOptions{PerPage: 100}, + } + + var allRepos []*github.Repository + for { + repos, resp, err := client.Repositories.List(ctx, "", opt) + if err != nil { + return nil, err + } + allRepos = append(allRepos, repos...) + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + + return toRepositoryList(allRepos, false), nil +} + +func fetchStarredRepositories(ctx context.Context, client *github.Client, username string) ([]*Repository, error) { + opt := &github.ActivityListStarredOptions{ + ListOptions: github.ListOptions{PerPage: 100}, + } + + var allRepos []*github.Repository + + if username != "" { + // Use user-specific endpoint + for { + starred, resp, err := client.Activity.ListStarred(ctx, username, opt) + if err != nil { + return nil, err + } + for _, s := range starred { + allRepos = append(allRepos, s.Repository) + } + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + } else { + // Use authenticated user endpoint + for { + starred, resp, err := client.Activity.ListStarred(ctx, "", opt) + if err != nil { + return nil, err + } + for _, s := range starred { + allRepos = append(allRepos, s.Repository) + } + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + } + + repos := toRepositoryList(allRepos, false) + for _, repo := range repos { + repo.Starred = true + } + + return repos, nil +} + +func fetchOrganizationRepositories(ctx context.Context, client *github.Client, username string, includeOrgs, excludeOrgs []string, preserveOrgStructure, privateRepoAccess bool) ([]*Repository, error) { + opt := &github.ListOptions{PerPage: 100} + + var allOrgs []*github.Organization + + if username != "" { + // Use user-specific endpoint + for { + orgs, resp, err := client.Organizations.List(ctx, username, opt) + if err != nil { + return nil, err + } + allOrgs = append(allOrgs, orgs...) + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + } else { + // Use authenticated user endpoint + for { + orgs, resp, err := client.Organizations.List(ctx, "", opt) + if err != nil { + return nil, err + } + allOrgs = append(allOrgs, orgs...) + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + } + + // Filter organizations + var orgsToProcess []*github.Organization + for _, org := range allOrgs { + orgName := org.GetLogin() + + // Check include list + if len(includeOrgs) > 0 { + include := false + for _, includeName := range includeOrgs { + if orgName == includeName { + include = true + break + } + } + if !include { + continue + } + } + + // Check exclude list + exclude := false + for _, excludeName := range excludeOrgs { + if orgName == excludeName { + exclude = true + break + } + } + if exclude { + continue + } + + orgsToProcess = append(orgsToProcess, org) + } + + log.Printf("Processing repositories from %d organizations", len(orgsToProcess)) + + var allOrgRepos []*Repository + for _, org := range orgsToProcess { + orgName := org.GetLogin() + log.Printf("Fetching repositories for organization: %s", orgName) + + var orgRepos []*github.Repository + + if privateRepoAccess { + // Use search API for both public and private repositories + log.Printf("Using search API to fetch both public and private repositories for org: %s", orgName) + searchQuery := fmt.Sprintf("org:%s", orgName) + + searchOpt := &github.SearchOptions{ + ListOptions: github.ListOptions{PerPage: 100}, + } + + for { + result, resp, err := client.Search.Repositories(ctx, searchQuery, searchOpt) + if err != nil { + log.Printf("Error fetching repositories for org %s: %v", orgName, err) + break + } + orgRepos = append(orgRepos, result.Repositories...) + if resp.NextPage == 0 { + break + } + searchOpt.Page = resp.NextPage + } + + log.Printf("Found %d repositories (public and private) for org: %s", len(orgRepos), orgName) + } else { + // Use standard API for public repositories only + repoOpt := &github.RepositoryListByOrgOptions{ + ListOptions: github.ListOptions{PerPage: 100}, + } + + for { + repos, resp, err := client.Repositories.ListByOrg(ctx, orgName, repoOpt) + if err != nil { + log.Printf("Error fetching repositories for org %s: %v", orgName, err) + break + } + orgRepos = append(orgRepos, repos...) + if resp.NextPage == 0 { + break + } + repoOpt.Page = resp.NextPage + } + + log.Printf("Found %d public repositories for org: %s", len(orgRepos), orgName) + } + + repos := toRepositoryList(orgRepos, preserveOrgStructure) + if preserveOrgStructure { + for _, repo := range repos { + repo.Organization = orgName + } + } + allOrgRepos = append(allOrgRepos, repos...) + } + + return allOrgRepos, nil +} + +func withoutForks(repositories []*Repository) []*Repository { + var result []*Repository + for _, repo := range repositories { + if !repo.Fork { + result = append(result, repo) + } + } + return result +} + +func filterDuplicates(repositories []*Repository) []*Repository { + seen := make(map[string]bool) + var result []*Repository + + for _, repo := range repositories { + if !seen[repo.URL] { + seen[repo.URL] = true + result = append(result, repo) + } + } + + return result +} + +func toRepository(repo *github.Repository, preserveOrg bool) *Repository { + r := &Repository{ + Name: repo.GetName(), + URL: repo.GetCloneURL(), + Private: repo.GetPrivate(), + Fork: repo.GetFork(), + Owner: repo.GetOwner().GetLogin(), + FullName: repo.GetFullName(), + HasIssues: repo.GetHasIssues(), + } + return r +} + +func toRepositoryList(repos []*github.Repository, preserveOrg bool) []*Repository { + result := make([]*Repository, 0, len(repos)) + for _, repo := range repos { + result = append(result, toRepository(repo, preserveOrg)) + } + return result +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..727aa35 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/jaedle/mirror-to-gitea + +go 1.24.9 + +require ( + github.com/bmatcuk/doublestar/v4 v4.9.1 + github.com/google/go-github/v66 v66.0.0 + golang.org/x/oauth2 v0.33.0 +) + +require github.com/google/go-querystring v1.1.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a9c1a22 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v66 v66.0.0 h1:ADJsaXj9UotwdgK8/iFZtv7MLc8E8WBl62WLd/D/9+M= +github.com/google/go-github/v66 v66.0.0/go.mod h1:+4SO9Zkuyf8ytMj0csN1NR/5OTR+MfqPp8P8dVlcvY4= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/logger/logger.go b/logger/logger.go new file mode 100644 index 0000000..92cd000 --- /dev/null +++ b/logger/logger.go @@ -0,0 +1,103 @@ +package logger + +import ( + "encoding/json" + "fmt" + "log" + "time" + + "github.com/jaedle/mirror-to-gitea/config" +) + +type Logger struct { + prefix string +} + +func New() *Logger { + return &Logger{prefix: ""} +} + +func (l *Logger) Info(msg string, args ...interface{}) { + timestamp := time.Now().Format(time.RFC3339) + if len(args) > 0 { + log.Printf("[%s] INFO: %s %v\n", timestamp, msg, args) + } else { + log.Printf("[%s] INFO: %s\n", timestamp, msg) + } +} + +func (l *Logger) Error(msg string, args ...interface{}) { + timestamp := time.Now().Format(time.RFC3339) + if len(args) > 0 { + log.Printf("[%s] ERROR: %s %v\n", timestamp, msg, args) + } else { + log.Printf("[%s] ERROR: %s\n", timestamp, msg) + } +} + +func (l *Logger) ShowConfig(cfg *config.Config) { + // Create a copy of config with redacted tokens + redactedConfig := struct { + GitHub struct { + Username string `json:"username"` + Token string `json:"token"` + SkipForks bool `json:"skipForks"` + PrivateRepositories bool `json:"privateRepositories"` + MirrorIssues bool `json:"mirrorIssues"` + MirrorStarred bool `json:"mirrorStarred"` + MirrorOrganizations bool `json:"mirrorOrganizations"` + UseSpecificUser bool `json:"useSpecificUser"` + SingleRepo string `json:"singleRepo"` + IncludeOrgs []string `json:"includeOrgs"` + ExcludeOrgs []string `json:"excludeOrgs"` + PreserveOrgStructure bool `json:"preserveOrgStructure"` + SkipStarredIssues bool `json:"skipStarredIssues"` + } `json:"github"` + Gitea struct { + URL string `json:"url"` + Token string `json:"token"` + Organization string `json:"organization"` + Visibility string `json:"visibility"` + StarredReposOrg string `json:"starredReposOrg"` + } `json:"gitea"` + DryRun bool `json:"dryRun"` + Delay int `json:"delay"` + Include []string `json:"include"` + Exclude []string `json:"exclude"` + SingleRun bool `json:"singleRun"` + }{} + + redactedConfig.GitHub.Username = cfg.GitHub.Username + redactedConfig.GitHub.Token = "[REDACTED]" + redactedConfig.GitHub.SkipForks = cfg.GitHub.SkipForks + redactedConfig.GitHub.PrivateRepositories = cfg.GitHub.PrivateRepositories + redactedConfig.GitHub.MirrorIssues = cfg.GitHub.MirrorIssues + redactedConfig.GitHub.MirrorStarred = cfg.GitHub.MirrorStarred + redactedConfig.GitHub.MirrorOrganizations = cfg.GitHub.MirrorOrganizations + redactedConfig.GitHub.UseSpecificUser = cfg.GitHub.UseSpecificUser + redactedConfig.GitHub.SingleRepo = cfg.GitHub.SingleRepo + redactedConfig.GitHub.IncludeOrgs = cfg.GitHub.IncludeOrgs + redactedConfig.GitHub.ExcludeOrgs = cfg.GitHub.ExcludeOrgs + redactedConfig.GitHub.PreserveOrgStructure = cfg.GitHub.PreserveOrgStructure + redactedConfig.GitHub.SkipStarredIssues = cfg.GitHub.SkipStarredIssues + + redactedConfig.Gitea.URL = cfg.Gitea.URL + redactedConfig.Gitea.Token = "[REDACTED]" + redactedConfig.Gitea.Organization = cfg.Gitea.Organization + redactedConfig.Gitea.Visibility = cfg.Gitea.Visibility + redactedConfig.Gitea.StarredReposOrg = cfg.Gitea.StarredReposOrg + + redactedConfig.DryRun = cfg.DryRun + redactedConfig.Delay = cfg.Delay + redactedConfig.Include = cfg.Include + redactedConfig.Exclude = cfg.Exclude + redactedConfig.SingleRun = cfg.SingleRun + + configJSON, err := json.MarshalIndent(redactedConfig, "", " ") + if err != nil { + l.Error("Failed to marshal config", err) + return + } + + fmt.Printf("Applied configuration:\n%s\n", string(configJSON)) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..4f3f6be --- /dev/null +++ b/main.go @@ -0,0 +1,251 @@ +package main + +import ( + "context" + "log" + + "github.com/bmatcuk/doublestar/v4" + "github.com/jaedle/mirror-to-gitea/config" + "github.com/jaedle/mirror-to-gitea/gitea" + ghrepo "github.com/jaedle/mirror-to-gitea/github" + "github.com/jaedle/mirror-to-gitea/logger" + "github.com/google/go-github/v66/github" +) + +func main() { + // Load configuration + cfg, err := config.Load() + if err != nil { + log.Fatalf("invalid configuration: %v", err) + } + + lgr := logger.New() + lgr.ShowConfig(cfg) + + ctx := context.Background() + + // Create Gitea client + giteaClient := gitea.NewClient(&cfg.Gitea) + + // Create Gitea organization if specified + if cfg.Gitea.Organization != "" { + if err := giteaClient.CreateOrganization(cfg.Gitea.Organization, cfg.Gitea.Visibility, cfg.DryRun); err != nil { + log.Printf("Warning: Failed to create Gitea organization %s: %v", cfg.Gitea.Organization, err) + } + } + + // Create the starred repositories organization if mirror starred is enabled + if cfg.GitHub.MirrorStarred && cfg.Gitea.StarredReposOrg != "" { + if err := giteaClient.CreateOrganization(cfg.Gitea.StarredReposOrg, cfg.Gitea.Visibility, cfg.DryRun); err != nil { + log.Printf("Warning: Failed to create Gitea starred organization %s: %v", cfg.Gitea.StarredReposOrg, err) + } + } + + // Create GitHub client + ghClient := ghrepo.NewClient(cfg.GitHub.Token) + + // Get GitHub repositories + githubRepos, err := ghrepo.GetRepositories(ctx, ghClient, ghrepo.FetchOptions{ + Username: cfg.GitHub.Username, + PrivateRepositories: cfg.GitHub.PrivateRepositories, + SkipForks: cfg.GitHub.SkipForks, + MirrorStarred: cfg.GitHub.MirrorStarred, + MirrorOrganizations: cfg.GitHub.MirrorOrganizations, + SingleRepo: cfg.GitHub.SingleRepo, + IncludeOrgs: cfg.GitHub.IncludeOrgs, + ExcludeOrgs: cfg.GitHub.ExcludeOrgs, + PreserveOrgStructure: cfg.GitHub.PreserveOrgStructure, + UseSpecificUser: cfg.GitHub.UseSpecificUser, + }) + if err != nil { + log.Fatalf("Failed to fetch GitHub repositories: %v", err) + } + + // Apply include/exclude filters + filteredRepos := filterRepositories(githubRepos, cfg.Include, cfg.Exclude) + log.Printf("Found %d repositories to mirror", len(filteredRepos)) + + // Get Gitea user information + giteaUser, err := giteaClient.GetUser() + if err != nil { + log.Fatalf("Failed to get Gitea user: %v", err) + } + + // Create a map to store organization targets if preserving structure + orgTargets := make(map[string]*gitea.Target) + if cfg.GitHub.PreserveOrgStructure { + // Get unique organization names from repositories + uniqueOrgs := make(map[string]bool) + for _, repo := range filteredRepos { + if repo.Organization != "" { + uniqueOrgs[repo.Organization] = true + } + } + + // Create or get each organization in Gitea + for orgName := range uniqueOrgs { + log.Printf("Preparing Gitea organization for GitHub organization: %s", orgName) + + if err := giteaClient.CreateOrganization(orgName, cfg.Gitea.Visibility, cfg.DryRun); err != nil { + log.Printf("Error creating Gitea organization %s: %v", orgName, err) + continue + } + + orgTarget, err := giteaClient.GetOrganization(orgName) + if err != nil { + log.Printf("Error getting Gitea organization %s: %v", orgName, err) + continue + } + + orgTargets[orgName] = orgTarget + } + } + + // Mirror repositories + for _, repo := range filteredRepos { + if err := mirrorRepository(ctx, repo, cfg, giteaClient, ghClient, giteaUser, orgTargets); err != nil { + log.Printf("Error mirroring repository %s: %v", repo.Name, err) + } + } + + log.Println("Mirroring process completed") +} + +func filterRepositories(repos []*ghrepo.Repository, include, exclude []string) []*ghrepo.Repository { + var filtered []*ghrepo.Repository + + for _, repo := range repos { + // Check include patterns + includeMatch := false + for _, pattern := range include { + matched, err := doublestar.Match(pattern, repo.Name) + if err == nil && matched { + includeMatch = true + break + } + } + + if !includeMatch { + continue + } + + // Check exclude patterns + excludeMatch := false + for _, pattern := range exclude { + matched, err := doublestar.Match(pattern, repo.Name) + if err == nil && matched { + excludeMatch = true + break + } + } + + if !excludeMatch { + filtered = append(filtered, repo) + } + } + + return filtered +} + +func mirrorRepository( + ctx context.Context, + repo *ghrepo.Repository, + cfg *config.Config, + giteaClient *gitea.Client, + ghClient *github.Client, + giteaUser *gitea.Target, + orgTargets map[string]*gitea.Target, +) error { + // Determine the target (user or organization) + var giteaTarget *gitea.Target + + // For starred repositories, use the starred repos organization if configured + if repo.Starred && cfg.Gitea.StarredReposOrg != "" { + starredOrg, err := giteaClient.GetOrganization(cfg.Gitea.StarredReposOrg) + if err == nil { + log.Printf("Using organization \"%s\" for starred repository: %s", cfg.Gitea.StarredReposOrg, repo.Name) + giteaTarget = starredOrg + } else { + log.Printf("Could not find organization \"%s\" for starred repositories, using default target", cfg.Gitea.StarredReposOrg) + giteaTarget = getDefaultTarget(cfg, giteaClient, giteaUser) + } + } else if cfg.GitHub.PreserveOrgStructure && repo.Organization != "" { + // Use the organization as target + if target, ok := orgTargets[repo.Organization]; ok { + giteaTarget = target + } else { + log.Printf("No Gitea organization found for %s, using default target", repo.Organization) + giteaTarget = getDefaultTarget(cfg, giteaClient, giteaUser) + } + } else { + // Use the specified organization or user + giteaTarget = getDefaultTarget(cfg, giteaClient, giteaUser) + } + + // Check if already mirrored + isAlreadyMirrored, err := giteaClient.IsRepositoryMirrored(repo.Name, giteaTarget) + if err != nil { + return err + } + + // Special handling for starred repositories + if repo.Starred { + if isAlreadyMirrored { + log.Printf("Repository %s is already mirrored in %s %s; checking if it needs to be starred.", repo.Name, giteaTarget.Type, giteaTarget.Name) + return giteaClient.StarRepository(repo.Name, giteaTarget, cfg.DryRun) + } + if cfg.DryRun { + log.Printf("DRY RUN: Would mirror and star repository to %s %s: %s (starred)", giteaTarget.Type, giteaTarget.Name, repo.Name) + return nil + } + } else if isAlreadyMirrored { + log.Printf("Repository %s is already mirrored in %s %s; doing nothing.", repo.Name, giteaTarget.Type, giteaTarget.Name) + return nil + } else if cfg.DryRun { + log.Printf("DRY RUN: Would mirror repository to %s %s: %s", giteaTarget.Type, giteaTarget.Name, repo.Name) + return nil + } + + log.Printf("Mirroring repository to %s %s: %s%s", giteaTarget.Type, giteaTarget.Name, repo.Name, func() string { + if repo.Starred { + return " (will be starred)" + } + return "" + }()) + + // Mirror the repository + if err := giteaClient.MirrorRepository(repo, giteaTarget, cfg.GitHub.Token); err != nil { + return err + } + + // Star the repository if it's marked as starred + if repo.Starred { + if err := giteaClient.StarRepository(repo.Name, giteaTarget, cfg.DryRun); err != nil { + log.Printf("Warning: Failed to star repository %s: %v", repo.Name, err) + } + } + + // Mirror issues if requested + shouldMirrorIssues := cfg.GitHub.MirrorIssues && !(repo.Starred && cfg.GitHub.SkipStarredIssues) + + if shouldMirrorIssues && !cfg.DryRun { + if err := giteaClient.MirrorIssues(ctx, ghClient, repo, giteaTarget, cfg.GitHub.Token, cfg.DryRun); err != nil { + log.Printf("Warning: Failed to mirror issues for %s: %v", repo.Name, err) + } + } else if repo.Starred && cfg.GitHub.SkipStarredIssues { + log.Printf("Skipping issues for starred repository: %s", repo.Name) + } + + return nil +} + +func getDefaultTarget(cfg *config.Config, giteaClient *gitea.Client, giteaUser *gitea.Target) *gitea.Target { + if cfg.Gitea.Organization != "" { + org, err := giteaClient.GetOrganization(cfg.Gitea.Organization) + if err == nil { + return org + } + log.Printf("Warning: Failed to get Gitea organization %s, using user instead: %v", cfg.Gitea.Organization, err) + } + return giteaUser +} From fe1f93769e1886cb5bb2d57606d0a055f1254a70 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Nov 2025 22:39:23 +0000 Subject: [PATCH 3/3] Update README with Go conversion note Co-authored-by: veverkap <22348+veverkap@users.noreply.github.com> --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ddf6a80..ffc57d3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Automatically Mirror Github Repo To Your Gitea Server +> **Note:** This application has been converted from Node.js to Go for better performance, smaller Docker images, and easier deployment. The functionality remains the same. + This project is considered in maintenance mode and will only receive bug-fixes but likely no new features. A potential successor may be [Gitea-Mirror](https://github.com/RayLabsHQ/gitea-mirror). @@ -11,12 +13,12 @@ If you are interested in taking over the project, feel free to reach out to me. ## Description -This script automatically mirrors the repositories from a github-user or github-organization to your gitea server. +This application automatically mirrors the repositories from a github-user or github-organization to your gitea server. Once started, it will create a mirrored repository under a given token for a gitea user, completely automatically. Example: A github user `github-user` has public repositories `dotfiles` and `zsh-config`. -Starting the script with a gitea token for the account `gitea-user` will create the following mirrored repositories: +Starting the application with a gitea token for the account `gitea-user` will create the following mirrored repositories: - github.com/github-user/dotfiles → your-gitea.url/gitea-user/dotfiles - github.com/github-user/zsh-config → your-gitea.url/gitea-user/zsh-config