Merge pull request #3 from amalgamated-tools/copilot/convert-to-go

Convert application from Node.js to Go
This commit is contained in:
Patrick Veverka
2025-11-13 09:58:55 -05:00
committed by GitHub
13 changed files with 1635 additions and 48 deletions

View File

@@ -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 .

14
.gitignore vendored
View File

@@ -4,4 +4,16 @@ node_modules/
.vscode/
.task/
dist/
dist/
# Go build artifacts
mirror-to-gitea
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
go.work
go.work.sum

View File

@@ -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

View File

@@ -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
@@ -199,21 +201,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 +236,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

175
config/config.go Normal file
View File

@@ -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
}

272
config/config_test.go Normal file
View File

@@ -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)
}
})
}

View File

@@ -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;;

347
gitea/client.go Normal file
View File

@@ -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))
}

403
github/repositories.go Normal file
View File

@@ -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
}

11
go.mod Normal file
View File

@@ -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

12
go.sum Normal file
View File

@@ -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=

103
logger/logger.go Normal file
View File

@@ -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))
}

251
main.go Normal file
View File

@@ -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
}