mirror of
https://github.com/amalgamated-tools/mirror-to-gitea.git
synced 2025-12-23 22:18:05 -05:00
Merge pull request #3 from amalgamated-tools/copilot/convert-to-go
Convert application from Node.js to Go
This commit is contained in:
13
.github/workflows/test.yml
vendored
13
.github/workflows/test.yml
vendored
@@ -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 .
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -5,3 +5,15 @@ node_modules/
|
||||
.task/
|
||||
|
||||
dist/
|
||||
|
||||
# Go build artifacts
|
||||
mirror-to-gitea
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
go.work
|
||||
go.work.sum
|
||||
51
Dockerfile
51
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
|
||||
|
||||
29
README.md
29
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
|
||||
@@ -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
175
config/config.go
Normal 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
272
config/config_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
347
gitea/client.go
Normal 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
403
github/repositories.go
Normal 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
11
go.mod
Normal 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
12
go.sum
Normal 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
103
logger/logger.go
Normal 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
251
main.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user