diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..264bb93 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.*~ +*~ +.env +cache.json +bin/* diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..9ecec50 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // Используйте IntelliSense, чтобы узнать о возможных атрибутах. + // Наведите указатель мыши, чтобы просмотреть описания существующих атрибутов. + // Для получения дополнительной информации посетите: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Launch Package", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/main.go" + } + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7a3524e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM docker.io/golang:1.23-alpine AS builder + +ENV GOEXPORT=https://proxy.golang.org + +COPY . /build + +RUN cd /build && \ + go get ./... && \ + go build -ldflags '-s -w' -o abfmigrator && \ + go test ./... + +FROM scratch + +COPY --from=builder /build/abfmigrator . + +ENTRYPOINT ["./abfmigrator"] diff --git a/README.md b/README.md index 55596ca..fe8a6d2 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,57 @@ Утилита миграции репозиториев, которая создает зеркала на в основанных на gitea репозиториях расположенных в abf (abf.io, abf.openmandriva.org) + +## Сборка + +```bash +export GOPROXY=direct +go get ./... +go build -o abfmigrator . +``` + +## Тесты +```bash +go test ./... + +``` + +## Использование + +Создать в папке с программой файл `.env` + +``` +ABF_API_URL=https://abf.miproject.org +ABF_FILE_STORE_URL=https://abf.miproject.org +ABF_LOGIN=user +ABF_PASSWORD=password +GITEA_API_URL=https://mygitea.org/api/v1 +GITEA_TOKEN=token_user_gitea +GITEA_OWNER=owner_gitea +PLATFORM_ID=abf_id_platform +REPO_ID=abf_id_repo +OWNER_NAME=abf_owner +``` + +где +1. `ABF_API_URL` - адрес ABF +2. `ABF_FILE_STORE_URL` - адрес файлового хранилища (не используется) +3. `ABF_LOGIN` - имя пользователя для входа в ABF (login) +4. `ABF_PASSWORD` - пароль пользователя ABF +5. `GITEA_API_URL` - адрес gitea +6. `GITEA_TOKEN` - токен пользователя gitea для доступа к API +7. `GITEA_OWNER` - владелец репозитория, часто пользователь или организация +8. `PLATFORM_ID` - ID платформы в ABF +9. `REPO_ID` - ID репозитория в ABF +10. `OWNER_NAME` - владелец платформы и репозитрия в ABF + +## В docker +1. собрать контейнер +``` +docker build --network=host -t abfmigrator . +``` +2. запустить контейнер +``` +docker run -v $PWD/.env:/.env abfmigrator +``` + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c9b825c --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module abfmigration + +go 1.23.3 + +require github.com/joho/godotenv v1.5.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d61b19e --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= diff --git a/main.go b/main.go new file mode 100644 index 0000000..5ec7d39 --- /dev/null +++ b/main.go @@ -0,0 +1,798 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "flag" + "fmt" + "io/ioutil" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/joho/godotenv" +) + +// AbfJson структура для взаимодействия с API ABF.io +type AbfJson struct { + AbfURL string + FileStoreURL string + Login string + Password string + Base64AuthString string + Log *log.Logger + CacheData map[string][]byte + CacheEtags map[string]string +} + +// Ошибки API ABF.io +var errorsMap = map[string]error{ + "Invalid email or password.": fmt.Errorf("Authentication error"), + "403 Forbidden | Rate Limit Exceeded": fmt.Errorf("Rate limit exceeded"), + "Page not found": fmt.Errorf("Page not found"), + "Project has not been forked. Name has already been taken": fmt.Errorf("Name already taken"), + "Error 404. Resource not found!": fmt.Errorf("Resource not found"), + "Something went wrong. We've been notified about this issue and we'll take a look at it shortly.": fmt.Errorf("Internal server error"), + "We update the site, it will take some time. We are really trying to do it fast. We apologize for any inconvenience..": fmt.Errorf("Server under maintenance"), + "Requires authentication": fmt.Errorf("Authentication required"), + "Forbidden. Sorry, you don't have enough rights for this action!": fmt.Errorf("Forbidden"), + "Access violation to this page!": fmt.Errorf("Access violation"), + "Bad Request": fmt.Errorf("Bad request"), +} + +// GoodMessages сообщения, которые не считаются ошибками +var goodMessages = []string{ + "Errors during build publishing!", + "Build is queued for publishing", +} + +// FatalErrors фатальные ошибки, которые приводят к выходу из программы +var fatalErrors = []error{ + errorsMap["Invalid email or password."], + errorsMap["403 Forbidden | Rate Limit Exceeded"], + errorsMap["Something went wrong. We've been notified about this issue and we'll take a look at it shortly."], +} + +// Project структура для хранения информации о проекте +type Project struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +// ProjectDetails структура для хранения деталей проекта +type ProjectDetails struct { + ID int `json:"id"` + Name string `json:"name"` + URL string `json:"git_url"` + WebURL string `json:"web_url"` + SSHURLToRepo string `json:"ssh_url_to_repo"` + HTTPURLToRepo string `json:"http_url_to_repo"` +} + +// CacheEntry структура для хранения данных кеша +type CacheEntry struct { + Data map[string][]byte `json:"data"` + CacheEtags map[string]string `json:"cache_etags"` + Timestamp time.Time `json:"timestamp"` +} + +// NewAbfJson создает новый экземпляр AbfJson +func NewAbfJson(abfURL, fileStoreURL, login, password string, log *log.Logger) (*AbfJson, error) { + if !strings.HasPrefix(fileStoreURL, "http://") && !strings.HasPrefix(fileStoreURL, "https://") { + log.Fatalf("File-store URL has to start with 'http(s)://'") + } + + lpw := fmt.Sprintf("%s:%s", login, password) + encodedLpw := base64.StdEncoding.EncodeToString([]byte(lpw)) + + return &AbfJson{ + AbfURL: strings.TrimSuffix(abfURL, "/"), + FileStoreURL: strings.TrimSuffix(fileStoreURL, "/"), + Login: login, + Password: password, + Base64AuthString: encodedLpw, + Log: log, + CacheData: make(map[string][]byte), + CacheEtags: make(map[string]string), + }, nil +} + +// LoadCache загружает кеш из файла +func (a *AbfJson) LoadCache(cacheFilePath string) error { + data, err := ioutil.ReadFile(cacheFilePath) + if err != nil { + if os.IsNotExist(err) { + a.Log.Println("Cache file does not exist. Proceeding without cache.") + return nil + } + return fmt.Errorf("failed to read cache file: %v", err) + } + + var cacheEntry CacheEntry + if err := json.Unmarshal(data, &cacheEntry); err != nil { + return fmt.Errorf("failed to unmarshal cache data: %v", err) + } + + a.CacheData = cacheEntry.Data + a.CacheEtags = cacheEntry.CacheEtags + a.Log.Println("Cache loaded successfully.") + return nil +} + +// SaveCache сохраняет кеш в файл +func (a *AbfJson) SaveCache(cacheFilePath string) error { + cacheEntry := CacheEntry{ + Data: a.CacheData, + CacheEtags: a.CacheEtags, + Timestamp: time.Now(), + } + + data, err := json.Marshal(cacheEntry) + if err != nil { + return fmt.Errorf("failed to marshal cache data: %v", err) + } + + if err := ioutil.WriteFile(cacheFilePath, data, 0644); err != nil { + return fmt.Errorf("failed to write cache file: %v", err) + } + + a.Log.Println("Cache saved successfully.") + return nil +} + +// IsCacheValid проверяет, действителен ли кеш +func (a *AbfJson) IsCacheValid(cacheFilePath string, ttl time.Duration) (bool, error) { + data, err := ioutil.ReadFile(cacheFilePath) + if err != nil { + if os.IsNotExist(err) { + a.Log.Println("Cache file does not exist. Cache is invalid.") + return false, nil + } + return false, fmt.Errorf("failed to read cache file: %v", err) + } + + var cacheEntry CacheEntry + if err := json.Unmarshal(data, &cacheEntry); err != nil { + return false, fmt.Errorf("failed to unmarshal cache data: %v", err) + } + + if time.Since(cacheEntry.Timestamp) > ttl { + a.Log.Println("Cache has expired.") + return false, nil + } + + a.Log.Println("Cache is valid.") + return true, nil +} + +// ProcessResponse обрабатывает ответ API ABF.io +func (a *AbfJson) ProcessResponse(responseString []byte) (map[string]interface{}, error) { + var res map[string]interface{} + if err := json.Unmarshal(responseString, &res); err != nil { + a.Log.Fatalf("Internal server error: it has returned non-json data.") + } + + message, ok := res["message"].(string) + if ok && !contains(goodMessages, message) { + return nil, errorsMap[message] + } + + repository, repoOk := res["repository"].(map[string]interface{}) + project, projOk := res["project"].(map[string]interface{}) + + if repoOk { + repoMessage, ok := repository["message"].(string) + if ok && (strings.Contains(repoMessage, "error") || strings.Contains(repoMessage, "has not been")) { + return nil, errorsMap[repoMessage] + } + } + + if projOk { + projMessage, ok := project["message"].(string) + if ok && (strings.Contains(projMessage, "error") || strings.Contains(projMessage, "has not been")) { + return nil, errorsMap[projMessage] + } + } + + if errStr, ok := res["error"].(string); ok && !contains(goodMessages, errStr) { + return nil, errorsMap[errStr] + } + + return res, nil +} + +// GetURLContents выполняет HTTP-запрос к API ABF.io +func (a *AbfJson) GetURLContents(path string, params map[string]string, method string, body interface{}) (interface{}, error) { + url := a.AbfURL + path + if method == "file_store" { + url = a.FileStoreURL + path + } + + if len(params) > 0 { + query := "" + for key, value := range params { + if query == "" { + query += "?" + } else { + query += "&" + } + query += fmt.Sprintf("%s=%s", key, value) + } + url += query + } + + a.Log.Printf("Fetching URL: %s", url) + + var req *http.Request + var err error + var requestBody []byte + + if body != nil { + requestBody, err = json.Marshal(body) + if err != nil { + a.Log.Fatalf("Failed to marshal request body: %v", err) + } + var prettyJSON bytes.Buffer + json.Indent(&prettyJSON, requestBody, "", " ") + a.Log.Printf("Request Body: %s", prettyJSON.String()) // Логирование отправляемого JSON + } + + switch method { + case "POST": + req, err = http.NewRequest("POST", url, bytes.NewBuffer(requestBody)) + case "PUT": + req, err = http.NewRequest("PUT", url, bytes.NewBuffer(requestBody)) + case "DELETE": + req, err = http.NewRequest("DELETE", url, bytes.NewBuffer(requestBody)) + default: + req, err = http.NewRequest("GET", url, nil) + } + + if err != nil { + a.Log.Fatalf("Failed to create request: %v", err) + } + + req.Header.Add("Authorization", fmt.Sprintf("Basic %s", a.Base64AuthString)) + req.Header.Add("Content-Type", "application/json") + + etag, etagFound := a.CacheEtags[url] + if etagFound { + req.Header.Add("If-None-Match", etag) + } + + client := &http.Client{ + Timeout: 30 * time.Second, + } + + resp, err := client.Do(req) + if err != nil { + a.Log.Fatalf("HTTP request failed: %v", err) + } + defer resp.Body.Close() + + responseBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + a.Log.Fatalf("Failed to read response body: %v", err) + } + + if resp.StatusCode == 304 && etagFound { + a.Log.Println("Getting cached result (cache was validated)") + return a.ProcessResponse(a.CacheData[etag]) + } + + if resp.StatusCode != 200 && resp.StatusCode != 201 { + a.Log.Printf("Return code: %d", resp.StatusCode) + return nil, fmt.Errorf("HTTP error %d: %s", resp.StatusCode, string(responseBody)) + } + + etagNew := resp.Header.Get("ETag") + if etagNew != "" { + a.Log.Printf("Caching the new value for %s. ETag is %s", url, etagNew) + a.CacheEtags[url] = etagNew + a.CacheData[etagNew] = responseBody + } + + var res interface{} + if err := json.Unmarshal(responseBody, &res); err != nil { + a.Log.Fatalf("Failed to unmarshal response: %v", err) + } + + return res, nil +} + +// GetProjectsList получает список проектов с обходом всех страниц +func (a *AbfJson) GetProjectsList(platformID, repoID int, ownerName string) ([]Project, error) { + var allProjects []Project + page := 1 + perPage := 100 + + for { + params := map[string]string{ + "added": "true", + "format": "json", + "owner_name": ownerName, + "page": fmt.Sprintf("%d", page), + "per_page": fmt.Sprintf("%d", perPage), + "project_name": "", + } + path := fmt.Sprintf("/platforms/%d/repositories/%d/projects_list", platformID, repoID) + res, err := a.GetURLContents(path, params, "GET", nil) + if err != nil { + return nil, fmt.Errorf("Failed to get projects list: %v", err) + } + + // Преобразуем res["projects"] в []byte + projectsArray, ok := res.(map[string]interface{})["projects"] + if !ok { + a.Log.Fatalf("Failed to find 'projects' in response") + } + + projectsBytes, err := json.Marshal(projectsArray) + if err != nil { + a.Log.Fatalf("Failed to marshal projects list: %v", err) + } + + var projects []Project + if err := json.Unmarshal(projectsBytes, &projects); err != nil { + a.Log.Fatalf("Failed to unmarshal projects list: %v", err) + } + + allProjects = append(allProjects, projects...) + + if len(projects) < perPage { + break + } + page++ + } + + return allProjects, nil +} + +// GetProjectDetails получает детали проекта по ID +func (a *AbfJson) GetProjectDetails(projectID int) (*ProjectDetails, error) { + path := fmt.Sprintf("/api/v1/projects/%d.json", projectID) + res, err := a.GetURLContents(path, nil, "GET", nil) + if err != nil { + a.Log.Printf("Failed to get project details for project ID %d: %v", projectID, err) + return nil, err + } + + projectData, ok := res.(map[string]interface{})["project"].(map[string]interface{}) + if !ok { + a.Log.Printf("Missing 'project' field in response for project ID %d", projectID) + return nil, fmt.Errorf("Missing 'project' field in response") + } + + id, idOk := projectData["id"] + if !idOk { + a.Log.Printf("Missing 'id' field in project details for project ID %d", projectID) + return nil, fmt.Errorf("Missing 'id' field in project details") + } + + idFloat, idOk := id.(float64) + if !idOk { + a.Log.Printf("Invalid 'id' type in project details for project ID %d: expected float64, got %T", projectID, id) + return nil, fmt.Errorf("Invalid 'id' type in project details") + } + + name, nameOk := projectData["name"].(string) + if !nameOk { + a.Log.Printf("Missing 'name' field in project details for project ID %d", projectID) + return nil, fmt.Errorf("Missing 'name' field in project details") + } + + gitUrl, gitUrlOk := projectData["git_url"].(string) + webUrl, webUrlOk := projectData["web_url"].(string) + sshUrlToRepo, sshUrlToRepoOk := projectData["ssh_url_to_repo"].(string) + httpUrlToRepo, httpUrlToRepoOk := projectData["http_url_to_repo"].(string) + + var url string + if gitUrlOk { + url = gitUrl + } else if httpUrlToRepoOk { + url = httpUrlToRepo + } else if webUrlOk { + url = webUrl + } else if sshUrlToRepoOk { + url = sshUrlToRepo + } else { + a.Log.Printf("Missing URL fields in project details for project ID %d", projectID) + return nil, fmt.Errorf("Missing URL fields in project details") + } + + projectDetails := &ProjectDetails{ + ID: int(idFloat), + Name: name, + URL: url, + WebURL: webUrl, + SSHURLToRepo: sshUrlToRepo, + HTTPURLToRepo: httpUrlToRepo, + } + + return projectDetails, nil +} + +// contains проверяет, содержит ли слайс строк строку +func contains(slice []string, str string) bool { + for _, item := range slice { + if item == str { + return true + } + } + return false +} + +// GiteaClient структура для взаимодействия с API Gitea +type GiteaClient struct { + APIURL string + Token string + Owner string + Log *log.Logger +} + +// Repository структура для хранения информации о репозитории в Gitea +type Repository struct { + ID int `json:"id"` + Name string `json:"name"` +} + +// GetRepositories получает список репозиториев из Gitea +func (g *GiteaClient) GetRepositories() ([]Repository, error) { + path := "/user/repos" + res, err := g.GetURLContents(path, nil, "GET", nil) + if err != nil { + return nil, fmt.Errorf("Failed to get repositories: %v", err) + } + + // Проверяем, что res является массивом + reposArray, ok := res.([]interface{}) + if !ok { + g.Log.Fatalf("Expected array in response, got %T", res) + } + + var repos []Repository + for _, repoInterface := range reposArray { + repoMap, ok := repoInterface.(map[string]interface{}) + if !ok { + g.Log.Fatalf("Expected map in array element, got %T", repoInterface) + } + + id, idOk := repoMap["id"].(float64) + if !idOk { + g.Log.Fatalf("Invalid 'id' type in repository: expected float64, got %T", repoMap["id"]) + } + + name, nameOk := repoMap["name"].(string) + if !nameOk { + g.Log.Fatalf("Missing 'name' field in repository") + } + + repos = append(repos, Repository{ + ID: int(id), + Name: name, + }) + } + + return repos, nil +} + +// MirrorRepositoryPayload структура для запроса зеркалирования репозитория в Gitea +type MirrorRepositoryPayload struct { + AuthPassword string `json:"auth_password,omitempty"` + AuthToken string `json:"auth_token,omitempty"` + AuthUsername string `json:"auth_username,omitempty"` + CloneAddr string `json:"clone_addr"` + Description string `json:"description,omitempty"` + Issues bool `json:"issues"` + Labels bool `json:"labels"` + LFS bool `json:"lfs"` + LFSEndpoint string `json:"lfs_endpoint,omitempty"` + Milestones bool `json:"milestones"` + Mirror bool `json:"mirror"` + MirrorInterval string `json:"mirror_interval,omitempty"` + Private bool `json:"private"` + PullRequests bool `json:"pull_requests"` + Releases bool `json:"releases"` + RepoName string `json:"repo_name"` + RepoOwner string `json:"repo_owner"` + Service string `json:"service"` + UID int `json:"uid,omitempty"` + Wiki bool `json:"wiki"` +} + +// MirrorRepository создает зеркало репозитория в Gitea +func (g *GiteaClient) MirrorRepository(repoName, cloneURL string) error { + payload := MirrorRepositoryPayload{ + CloneAddr: cloneURL, + Description: "", + Issues: true, + Labels: true, + LFS: false, + Milestones: true, + Mirror: true, + MirrorInterval: "8h0m0s", // Интервал зеркалирования + Private: false, + PullRequests: true, + Releases: true, + RepoName: repoName, + RepoOwner: g.Owner, + Service: "git", + Wiki: true, + } + + path := "/repos/migrate" + res, err := g.GetURLContents(path, nil, "POST", payload) + if err != nil { + return fmt.Errorf("Failed to mirror repository: %v", err) + } + + if res == nil { + return fmt.Errorf("Empty response from Gitea") + } + + g.Log.Printf("Response from Gitea: %+v", res) + return nil +} + +// GetURLContents выполняет HTTP-запрос к API Gitea +func (g *GiteaClient) GetURLContents(path string, params map[string]string, method string, body interface{}) (interface{}, error) { + url := g.APIURL + path + + if len(params) > 0 { + query := "" + for key, value := range params { + if query == "" { + query += "?" + } else { + query += "&" + } + query += fmt.Sprintf("%s=%s", key, value) + } + url += query + } + + g.Log.Printf("Fetching URL: %s", url) + + var req *http.Request + var err error + var requestBody []byte + + if body != nil { + requestBody, err = json.Marshal(body) + if err != nil { + g.Log.Fatalf("Failed to marshal request body: %v", err) + } + var prettyJSON bytes.Buffer + json.Indent(&prettyJSON, requestBody, "", " ") + g.Log.Printf("Request Body: %s", prettyJSON.String()) // Логирование отправляемого JSON + } + + switch method { + case "POST": + req, err = http.NewRequest("POST", url, bytes.NewBuffer(requestBody)) + case "PUT": + req, err = http.NewRequest("PUT", url, bytes.NewBuffer(requestBody)) + case "DELETE": + req, err = http.NewRequest("DELETE", url, bytes.NewBuffer(requestBody)) + default: + req, err = http.NewRequest("GET", url, nil) + } + + if err != nil { + g.Log.Fatalf("Failed to create request: %v", err) + } + + req.Header.Add("Authorization", fmt.Sprintf("token %s", g.Token)) + req.Header.Add("Content-Type", "application/json") + + client := &http.Client{ + Timeout: 30 * time.Second, + } + + resp, err := client.Do(req) + if err != nil { + g.Log.Fatalf("HTTP request failed: %v", err) + } + defer resp.Body.Close() + + responseBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + g.Log.Fatalf("Failed to read response body: %v", err) + } + + if resp.StatusCode != 200 && resp.StatusCode != 201 { + g.Log.Printf("Return code: %d", resp.StatusCode) + return nil, fmt.Errorf("HTTP error %d: %s", resp.StatusCode, string(responseBody)) + } + + var res interface{} + if err := json.Unmarshal(responseBody, &res); err != nil { + g.Log.Fatalf("Failed to unmarshal response: %v", err) + } + + return res, nil +} + +func main() { + // Флаги для параметров проектов + platformIDFlag := flag.Int("platformid", 0, "Platform ID") + repoIDFlag := flag.Int("repoid", 0, "Repository ID") + ownerFlag := flag.String("owner", "", "Owner name") + cacheTTLFlag := flag.Duration("cachettl", 12*time.Hour, "Cache TTL duration") + forceFlag := flag.Bool("force", false, "Force refresh cache") + cacheFilePathFlag := flag.String("cachefile", "cache.json", "Cache file path") + + flag.Parse() + + // Загрузка переменных окружения из файла .env + err := godotenv.Load() + if err != nil { + log.Fatalf("Error loading .env file: %v", err) + } + + abfURL := os.Getenv("ABF_API_URL") + fileStoreURL := os.Getenv("ABF_FILE_STORE_URL") + login := os.Getenv("ABF_LOGIN") + password := os.Getenv("ABF_PASSWORD") + giteaAPIURL := os.Getenv("GITEA_API_URL") + giteaToken := os.Getenv("GITEA_TOKEN") + giteaOwner := os.Getenv("GITEA_OWNER") + cacheFilePath := *cacheFilePathFlag + + // Приоритет флагов над переменными окружения + platformID := *platformIDFlag + if platformID == 0 { + platformIDStr := os.Getenv("PLATFORM_ID") + if platformIDStr == "" { + log.Fatalf("Platform ID is not set either via --platformid flag or PLATFORM_ID environment variable") + } + platformID, err = strconv.Atoi(platformIDStr) + if err != nil { + log.Fatalf("Invalid PLATFORM_ID value: %v", err) + } + } + + repoID := *repoIDFlag + if repoID == 0 { + repoIDStr := os.Getenv("REPO_ID") + if repoIDStr == "" { + log.Fatalf("Repository ID is not set either via --repoid flag or REPO_ID environment variable") + } + repoID, err = strconv.Atoi(repoIDStr) + if err != nil { + log.Fatalf("Invalid REPO_ID value: %v", err) + } + } + + ownerName := *ownerFlag + if ownerName == "" { + ownerName = os.Getenv("OWNER_NAME") + if ownerName == "" { + log.Fatalf("Owner name is not set either via --owner flag or OWNER_NAME environment variable") + } + } + + logger := log.New(os.Stdout, "", log.LstdFlags) + + abfClient, err := NewAbfJson(abfURL, fileStoreURL, login, password, logger) + if err != nil { + logger.Fatalf("Failed to create ABF client: %v", err) + } + + // Проверка кеша + cacheValid, err := abfClient.IsCacheValid(cacheFilePath, *cacheTTLFlag) + if err != nil { + logger.Fatalf("Failed to check cache validity: %v", err) + } + + if cacheValid && !*forceFlag { + logger.Println("Using cached data.") + } else { + logger.Println("Refreshing cache.") + + // Получение списка проектов + projects, err := abfClient.GetProjectsList(platformID, repoID, ownerName) + if err != nil { + logger.Fatalf("Failed to get projects: %v", err) + } + + logger.Printf("Total projects found: %d", len(projects)) + + // Сбор ID и имени проектов + projectIDs := make(map[int]string) + for _, project := range projects { + projectIDs[project.ID] = project.Name + } + + // Получение деталей проектов и их URL + var projectDetailsList []*ProjectDetails + for projectID, projectName := range projectIDs { + details, err := abfClient.GetProjectDetails(projectID) + if err != nil { + logger.Printf("Failed to get details for project %d (%s): %v", projectID, projectName, err) + continue + } + projectDetailsList = append(projectDetailsList, details) + } + + // Сохранение кеша + abfClient.CacheData = make(map[string][]byte) + for _, details := range projectDetailsList { + detailsBytes, err := json.Marshal(details) + if err != nil { + logger.Fatalf("Failed to marshal project details: %v", err) + } + abfClient.CacheData[strconv.Itoa(details.ID)] = detailsBytes + } + + if err := abfClient.SaveCache(cacheFilePath); err != nil { + logger.Fatalf("Failed to save cache: %v", err) + } + + // Вывод списка проектов с URL + for _, details := range projectDetailsList { + logger.Printf("Project ID: %d, Name: %s, URL: %s", details.ID, details.Name, details.URL) + } + } + + // Загрузка кеша для дальнейшей работы + if err := abfClient.LoadCache(cacheFilePath); err != nil { + logger.Fatalf("Failed to load cache: %v", err) + } + + // Получение списка репозиториев из Gitea + giteaClient := &GiteaClient{ + APIURL: giteaAPIURL, + Token: giteaToken, + Owner: giteaOwner, + Log: logger, + } + + giteaRepos, err := giteaClient.GetRepositories() + if err != nil { + logger.Fatalf("Failed to get repositories from Gitea: %v", err) + } + + giteaRepoNames := make(map[string]struct{}) + for _, repo := range giteaRepos { + giteaRepoNames[repo.Name] = struct{}{} + } + + // Миграция репозиториев в Gitea + for projectIDStr, detailsBytes := range abfClient.CacheData { + var projectDetails ProjectDetails + if err := json.Unmarshal(detailsBytes, &projectDetails); err != nil { + logger.Printf("Failed to unmarshal project details: %v", err) + continue + } + + projectID, err := strconv.Atoi(projectIDStr) + if err != nil { + logger.Printf("Недопустимый идентификатор проекта %s: %v", projectIDStr, err) + continue + } + + // Используйте переменную projectID здесь + logger.Printf("Идентификатор проекта: %d", projectID) + + repoName := projectDetails.Name + cloneURL := projectDetails.URL + + if _, exists := giteaRepoNames[repoName]; exists { + logger.Printf("Repository %s already exists in Gitea. Skipping...", repoName) + continue + } + + err = giteaClient.MirrorRepository(repoName, cloneURL) + if err != nil { + logger.Printf("Failed to mirror repository %s: %v", repoName, err) + } else { + logger.Printf("Repository %s mirrored to Gitea.", repoName) + } + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..375e2a3 --- /dev/null +++ b/main_test.go @@ -0,0 +1,140 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "log" +) + +// Тестирование метода MirrorRepository с неверным телом запроса +func TestMirrorRepositoryInvalidPayload(t *testing.T) { + // Создаем мок сервера + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.Method != "POST" { + rw.WriteHeader(http.StatusMethodNotAllowed) + return + } + + contentType := req.Header.Get("Content-Type") + if contentType != "application/json" { + rw.WriteHeader(http.StatusBadRequest) + return + } + + auth := req.Header.Get("Authorization") + if auth != "token test-token" { + rw.WriteHeader(http.StatusUnauthorized) + return + } + + var payload MirrorRepositoryPayload + err := json.NewDecoder(req.Body).Decode(&payload) + if err != nil { + rw.WriteHeader(http.StatusBadRequest) + return + } + + // Проверяем корректность полей payload + if payload.CloneAddr != "https://djam@abf.io/djam/btunlock.git" || + payload.RepoName != "btunlock" || + payload.RepoOwner != "djam" || + !payload.Issues || + !payload.Labels || + payload.LFS || + !payload.Milestones || + !payload.Mirror || + payload.MirrorInterval != "8h0m0s" || + payload.Private || + !payload.PullRequests || + !payload.Releases || + payload.Service != "git" || + !payload.Wiki { + rw.WriteHeader(http.StatusBadRequest) + return + } + + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(http.StatusCreated) + rw.Write([]byte(`{"id": 123, "name": "btunlock", "full_name": "djam/btunlock"}`)) + })) + + defer server.Close() + + // Создаем клиент для тестирования + giteaClient := &GiteaClient{ + APIURL: server.URL, + Token: "test-token", + Owner: "djam", + Log: log.New(os.Stdout, "", log.LstdFlags), + } + + // Вызываем метод MirrorRepository с неверным телом запроса + err := giteaClient.MirrorRepository("btunlock", "") + if err == nil { + t.Fatalf("Expected error, got nil") + } + + // Проверяем, что ошибка соответствует ожидаемому сообщению + expectedErrorMessage := "Failed to mirror repository: HTTP error 400: " + if err.Error() != expectedErrorMessage { + t.Errorf("Expected error message %q, got %q", expectedErrorMessage, err.Error()) + } +} + +// Тестирование метода MirrorRepository с ошибкой 422 +func TestMirrorRepositoryUnprocessableEntity(t *testing.T) { + // Создаем мок сервера + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.Method != "POST" { + rw.WriteHeader(http.StatusMethodNotAllowed) + return + } + + contentType := req.Header.Get("Content-Type") + if contentType != "application/json" { + rw.WriteHeader(http.StatusBadRequest) + return + } + + auth := req.Header.Get("Authorization") + if auth != "token test-token" { + rw.WriteHeader(http.StatusUnauthorized) + return + } + + var payload MirrorRepositoryPayload + err := json.NewDecoder(req.Body).Decode(&payload) + if err != nil { + rw.WriteHeader(http.StatusBadRequest) + return + } + + rw.WriteHeader(http.StatusUnprocessableEntity) + })) + + defer server.Close() + + // Создаем клиент для тестирования + giteaClient := &GiteaClient{ + APIURL: server.URL, + Token: "test-token", + Owner: "djam", + Log: log.New(os.Stdout, "", log.LstdFlags), + } + + // Вызываем метод MirrorRepository с ошибкой 422 + err := giteaClient.MirrorRepository("btunlock", "") + if err == nil { + t.Fatalf("Expected error, got nil") + } + + // Проверяем, что ошибка соответствует ожидаемому сообщению + expectedErrorMessage := "Failed to mirror repository: HTTP error 422: " + if err.Error() != expectedErrorMessage { + t.Errorf("Expected error message %q, got %q", expectedErrorMessage, err.Error()) + } +}