Some checks failed
Release / build-windows (push) Has been cancelled
✨ 新功能: - GitLab 平台完整支持(16+7 个 API 方法、前端图标/选择器) - Release 同步支持手动输入 Token 🐛 Bug 修复: - 自建平台凭证匹配(BaseURL 优先精确匹配) - Gitea 迁移 API service 字段类型修复(HTTP 422) - 构建时不再覆盖已有 config.yaml(数据丢失问题) - 错误信息显示实际 URL 🔨 改进: - 源平台开启代理时自动跳过 Gitea 原生迁移,改用本地代理中转 - Push 超时从 30s 提升到 10 分钟 - 设置 http.postBuffer=500MB,push --all 失败自动逐分支推送 - 新增 RunWithProxyTimeout 方法
421 lines
12 KiB
Go
421 lines
12 KiB
Go
package app
|
||
|
||
import (
|
||
"bytes"
|
||
"fmt"
|
||
"log"
|
||
"os"
|
||
"strings"
|
||
)
|
||
|
||
// --- 迁移相关类型 ---
|
||
|
||
// MigrateResult 迁移结果
|
||
type MigrateResult struct {
|
||
Path string `json:"path"`
|
||
Name string `json:"name"`
|
||
Success bool `json:"success"`
|
||
Message string `json:"message"`
|
||
CloneURL string `json:"cloneUrl,omitempty"`
|
||
RemoteName string `json:"remoteName,omitempty"`
|
||
}
|
||
|
||
// --- Release 同步相关类型 ---
|
||
|
||
// ReleaseSyncResult 单条 Release 同步结果
|
||
type ReleaseSyncResult struct {
|
||
TagName string `json:"tagName"`
|
||
Name string `json:"name"`
|
||
Success bool `json:"success"`
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
// --- 辅助:根据 remote URL 匹配 credential ---
|
||
|
||
// detectPlatformFromURL 从 remote URL 推断平台类型
|
||
func detectPlatformFromURL(remoteURL string) string {
|
||
u := strings.ToLower(remoteURL)
|
||
if strings.Contains(u, "github.com") {
|
||
return "github"
|
||
}
|
||
if strings.Contains(u, "gitee.com") {
|
||
return "gitee"
|
||
}
|
||
if strings.Contains(u, "gitlab.com") {
|
||
return "gitlab"
|
||
}
|
||
// 默认当作 Gitea(自建)
|
||
return "gitea"
|
||
}
|
||
|
||
// extractBaseURL 从 remote URL 提取 base URL(用于 Gitea 自建平台匹配)
|
||
func extractBaseURL(remoteURL string) string {
|
||
// https://host:port/owner/repo.git => https://host:port
|
||
// git@host:owner/repo.git => 不适用
|
||
if strings.HasPrefix(remoteURL, "git@") {
|
||
return ""
|
||
}
|
||
idx := strings.Index(remoteURL, "://")
|
||
if idx < 0 {
|
||
return ""
|
||
}
|
||
rest := remoteURL[idx+3:]
|
||
slashIdx := strings.Index(rest, "/")
|
||
if slashIdx < 0 {
|
||
return ""
|
||
}
|
||
return remoteURL[:idx+3+slashIdx]
|
||
}
|
||
|
||
// findCredentialForRemote 根据 remote URL 自动查找匹配的凭证
|
||
func (s *AppService) findCredentialForRemote(remoteURL string) (platform, baseURL, username, token string, found bool) {
|
||
plat := detectPlatformFromURL(remoteURL)
|
||
remoteBase := extractBaseURL(remoteURL)
|
||
|
||
// 1. 对于自建平台(gitea/gitlab 或未知),优先按 BaseURL 精确匹配
|
||
if plat == "gitea" || plat == "gitlab" {
|
||
if remoteBase != "" {
|
||
for _, c := range s.config.Credentials {
|
||
cp := strings.ToLower(c.Platform)
|
||
if (cp == "gitea" || cp == "gitlab") && c.BaseURL != "" {
|
||
if strings.EqualFold(strings.TrimRight(c.BaseURL, "/"), strings.TrimRight(remoteBase, "/")) {
|
||
return c.Platform, c.BaseURL, c.Username, c.Token, true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. 按平台名称匹配(github / gitee / gitlab.com 等公有平台)
|
||
for _, c := range s.config.Credentials {
|
||
if strings.ToLower(c.Platform) == plat {
|
||
return c.Platform, c.BaseURL, c.Username, c.Token, true
|
||
}
|
||
}
|
||
return "", "", "", "", false
|
||
}
|
||
|
||
// --- 迁移服务方法 ---
|
||
|
||
// MigrateProject 将项目迁移到目标平台
|
||
// targetCredPlatform + targetCredUsername 用于定位目标凭证
|
||
// repoName: 目标仓库名(空则用项目名)
|
||
// remoteName: 新增的远程名(空则用平台名)
|
||
// private: 是否私有仓库
|
||
// description: 仓库描述
|
||
func (s *AppService) MigrateProject(projectPath, targetCredPlatform, targetCredUsername, repoName, remoteName, description string, private bool) (*MigrateResult, error) {
|
||
result := &MigrateResult{Path: projectPath}
|
||
|
||
// 校验项目路径
|
||
if _, err := os.Stat(projectPath); os.IsNotExist(err) {
|
||
return nil, fmt.Errorf("项目路径不存在: %s", projectPath)
|
||
}
|
||
|
||
// 查找目标凭证
|
||
var cred *CredentialInfo
|
||
for _, c := range s.config.Credentials {
|
||
if strings.EqualFold(c.Platform, targetCredPlatform) && c.Username == targetCredUsername {
|
||
cred = &CredentialInfo{Platform: c.Platform, BaseURL: c.BaseURL, Username: c.Username, Token: c.Token}
|
||
break
|
||
}
|
||
}
|
||
if cred == nil {
|
||
return nil, fmt.Errorf("未找到目标凭证: %s/%s", targetCredPlatform, targetCredUsername)
|
||
}
|
||
if cred.Token == "" {
|
||
return nil, fmt.Errorf("目标凭证缺少 Token: %s/%s", targetCredPlatform, targetCredUsername)
|
||
}
|
||
|
||
// 创建平台 API 客户端
|
||
api, err := NewPlatformAPI(cred.Platform, cred.BaseURL, cred.Username, cred.Token)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("创建平台 API 失败: %w", err)
|
||
}
|
||
|
||
// 确定仓库名
|
||
if repoName == "" {
|
||
// 从项目名或路径推断
|
||
for _, p := range s.config.Projects {
|
||
if p.Path == projectPath {
|
||
repoName = p.Name
|
||
break
|
||
}
|
||
}
|
||
if repoName == "" {
|
||
parts := strings.Split(strings.ReplaceAll(projectPath, "\\", "/"), "/")
|
||
repoName = parts[len(parts)-1]
|
||
}
|
||
}
|
||
|
||
// 确定远程名
|
||
if remoteName == "" {
|
||
remoteName = strings.ToLower(cred.Platform)
|
||
// 如果已存在同名 remote,加上用户名
|
||
remoteList, _ := s.gitClient.RemoteList(projectPath)
|
||
for _, r := range remoteList {
|
||
if r.Name == remoteName {
|
||
remoteName = strings.ToLower(cred.Platform) + "-" + cred.Username
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
result.Name = repoName
|
||
result.RemoteName = remoteName
|
||
|
||
// 1. 通过 API 在目标平台创建仓库
|
||
cloneURL, err := api.CreateRepo(repoName, description, private)
|
||
if err != nil {
|
||
result.Message = fmt.Sprintf("创建仓库失败: %v", err)
|
||
return result, nil
|
||
}
|
||
result.CloneURL = cloneURL
|
||
log.Printf("迁移: 在 %s 上创建仓库 %s => %s", cred.Platform, repoName, cloneURL)
|
||
|
||
// 2. 添加 remote
|
||
_, err = s.gitClient.AddRemote(projectPath, remoteName, cloneURL)
|
||
if err != nil {
|
||
result.Message = fmt.Sprintf("仓库已创建(%s),但添加远程仓库失败: %v", cloneURL, err)
|
||
return result, nil
|
||
}
|
||
|
||
// 3. 推送所有分支到新 remote
|
||
proxy := s.GetProjectProxy(projectPath)
|
||
_, err = s.gitClient.RunWithProxy(projectPath, proxy, "push", remoteName, "--all")
|
||
if err != nil {
|
||
result.Message = fmt.Sprintf("推送分支失败: %v", err)
|
||
return result, nil
|
||
}
|
||
|
||
// 4. 推送所有标签到新 remote
|
||
_, err = s.gitClient.RunWithProxy(projectPath, proxy, "push", remoteName, "--tags")
|
||
if err != nil {
|
||
result.Message = fmt.Sprintf("分支已推送,但推送标签失败: %v", err)
|
||
return result, nil
|
||
}
|
||
|
||
result.Success = true
|
||
result.Message = "迁移成功"
|
||
return result, nil
|
||
}
|
||
|
||
// BatchMigrateProjects 批量迁移项目到目标平台
|
||
func (s *AppService) BatchMigrateProjects(projectPaths []string, targetCredPlatform, targetCredUsername, description string, private bool) []MigrateResult {
|
||
var results []MigrateResult
|
||
for _, path := range projectPaths {
|
||
result, err := s.MigrateProject(path, targetCredPlatform, targetCredUsername, "", "", description, private)
|
||
if err != nil {
|
||
results = append(results, MigrateResult{
|
||
Path: path,
|
||
Message: err.Error(),
|
||
})
|
||
} else {
|
||
results = append(results, *result)
|
||
}
|
||
}
|
||
return results
|
||
}
|
||
|
||
// --- Release 同步服务方法 ---
|
||
|
||
// GetRemoteReleases 获取指定远程仓库的 Release 列表
|
||
// overrideToken: 前端传入的 Token,优先于凭证中的 Token
|
||
func (s *AppService) GetRemoteReleases(projectPath, remoteName, overrideToken string) ([]ReleaseInfo, error) {
|
||
if _, err := os.Stat(projectPath); os.IsNotExist(err) {
|
||
return nil, fmt.Errorf("项目路径不存在: %s", projectPath)
|
||
}
|
||
|
||
// 获取 remote URL
|
||
remoteList, err := s.gitClient.RemoteList(projectPath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取远程仓库列表失败: %w", err)
|
||
}
|
||
|
||
var remoteURL string
|
||
for _, r := range remoteList {
|
||
if r.Name == remoteName {
|
||
remoteURL = r.URL
|
||
break
|
||
}
|
||
}
|
||
if remoteURL == "" {
|
||
return nil, fmt.Errorf("未找到远程仓库: %s", remoteName)
|
||
}
|
||
|
||
// 查找凭证
|
||
plat, baseURL, username, token, found := s.findCredentialForRemote(remoteURL)
|
||
if !found {
|
||
return nil, fmt.Errorf("未找到匹配的凭证,请先配置匹配 %s 的凭证", remoteURL)
|
||
}
|
||
if overrideToken != "" {
|
||
token = overrideToken
|
||
}
|
||
|
||
// 创建 API 客户端
|
||
api, err := NewPlatformAPI(plat, baseURL, username, token)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("创建平台 API 失败: %w", err)
|
||
}
|
||
|
||
// 解析 owner/repo
|
||
owner, repo, err := ParseOwnerRepo(remoteURL)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("解析远程地址失败: %w", err)
|
||
}
|
||
|
||
return api.ListReleases(owner, repo)
|
||
}
|
||
|
||
// SyncReleases 将源远程仓库的 Release 同步到目标远程仓库
|
||
// 仅同步目标不存在的 Release,包括附件
|
||
func (s *AppService) SyncReleases(projectPath, sourceRemote, targetRemote, srcOverrideToken, tgtOverrideToken string) ([]ReleaseSyncResult, error) {
|
||
if _, err := os.Stat(projectPath); os.IsNotExist(err) {
|
||
return nil, fmt.Errorf("项目路径不存在: %s", projectPath)
|
||
}
|
||
|
||
// 获取 remote URLs
|
||
remoteList, err := s.gitClient.RemoteList(projectPath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取远程仓库列表失败: %w", err)
|
||
}
|
||
|
||
var sourceURL, targetURL string
|
||
for _, r := range remoteList {
|
||
if r.Name == sourceRemote {
|
||
sourceURL = r.URL
|
||
}
|
||
if r.Name == targetRemote {
|
||
targetURL = r.URL
|
||
}
|
||
}
|
||
if sourceURL == "" {
|
||
return nil, fmt.Errorf("未找到源远程仓库: %s", sourceRemote)
|
||
}
|
||
if targetURL == "" {
|
||
return nil, fmt.Errorf("未找到目标远程仓库: %s", targetRemote)
|
||
}
|
||
|
||
// 查找源和目标凭证
|
||
srcPlat, srcBase, srcUser, srcToken, srcFound := s.findCredentialForRemote(sourceURL)
|
||
if !srcFound {
|
||
return nil, fmt.Errorf("未找到源平台的凭证,请先配置匹配 %s 的凭证", sourceURL)
|
||
}
|
||
if srcOverrideToken != "" {
|
||
srcToken = srcOverrideToken
|
||
}
|
||
tgtPlat, tgtBase, tgtUser, tgtToken, tgtFound := s.findCredentialForRemote(targetURL)
|
||
if !tgtFound {
|
||
return nil, fmt.Errorf("未找到目标平台的凭证,请先配置匹配 %s 的凭证", targetURL)
|
||
}
|
||
if tgtOverrideToken != "" {
|
||
tgtToken = tgtOverrideToken
|
||
}
|
||
|
||
// 创建 API 客户端
|
||
srcAPI, err := NewPlatformAPI(srcPlat, srcBase, srcUser, srcToken)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("创建源平台 API 失败: %w", err)
|
||
}
|
||
tgtAPI, err := NewPlatformAPI(tgtPlat, tgtBase, tgtUser, tgtToken)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("创建目标平台 API 失败: %w", err)
|
||
}
|
||
|
||
// 解析 owner/repo
|
||
srcOwner, srcRepo, err := ParseOwnerRepo(sourceURL)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("解析源远程地址失败: %w", err)
|
||
}
|
||
tgtOwner, tgtRepo, err := ParseOwnerRepo(targetURL)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("解析目标远程地址失败: %w", err)
|
||
}
|
||
|
||
// 获取源 Release 列表
|
||
srcReleases, err := srcAPI.ListReleases(srcOwner, srcRepo)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取源 Release 列表失败: %w", err)
|
||
}
|
||
|
||
// 获取目标 Release 列表,用于跳过已存在的
|
||
tgtReleases, err := tgtAPI.ListReleases(tgtOwner, tgtRepo)
|
||
if err != nil {
|
||
// 目标可能还没有 Release,不报错
|
||
tgtReleases = []ReleaseInfo{}
|
||
}
|
||
existingTags := make(map[string]bool)
|
||
for _, r := range tgtReleases {
|
||
existingTags[r.TagName] = true
|
||
}
|
||
|
||
var results []ReleaseSyncResult
|
||
|
||
// 先确保所有 tags 已推送到目标
|
||
proxy := s.GetProjectProxy(projectPath)
|
||
s.gitClient.RunWithProxy(projectPath, proxy, "push", targetRemote, "--tags")
|
||
|
||
// 逐个同步 Release
|
||
for _, srcRel := range srcReleases {
|
||
result := ReleaseSyncResult{
|
||
TagName: srcRel.TagName,
|
||
Name: srcRel.Name,
|
||
}
|
||
|
||
// 跳过已存在的
|
||
if existingTags[srcRel.TagName] {
|
||
result.Success = true
|
||
result.Message = "已存在,跳过"
|
||
results = append(results, result)
|
||
continue
|
||
}
|
||
|
||
// 创建 Release
|
||
created, err := tgtAPI.CreateRelease(tgtOwner, tgtRepo, ReleaseCreate{
|
||
TagName: srcRel.TagName,
|
||
Name: srcRel.Name,
|
||
Body: srcRel.Body,
|
||
Draft: srcRel.Draft,
|
||
Prerelease: srcRel.Prerelease,
|
||
})
|
||
if err != nil {
|
||
result.Message = fmt.Sprintf("创建 Release 失败: %v", err)
|
||
results = append(results, result)
|
||
continue
|
||
}
|
||
|
||
// 同步附件
|
||
assetErrors := []string{}
|
||
for _, asset := range srcRel.Assets {
|
||
if asset.DownloadURL == "" {
|
||
continue
|
||
}
|
||
// 下载附件
|
||
data, err := DownloadAsset(asset.DownloadURL, srcToken, srcPlat)
|
||
if err != nil {
|
||
assetErrors = append(assetErrors, fmt.Sprintf("%s: 下载失败(%v)", asset.Name, err))
|
||
continue
|
||
}
|
||
// 上传到目标
|
||
err = tgtAPI.UploadAsset(tgtOwner, tgtRepo, created.ID, asset.Name, bytes.NewReader(data))
|
||
if err != nil {
|
||
assetErrors = append(assetErrors, fmt.Sprintf("%s: 上传失败(%v)", asset.Name, err))
|
||
}
|
||
}
|
||
|
||
if len(assetErrors) > 0 {
|
||
result.Success = true
|
||
result.Message = "Release 已创建,但部分附件同步失败: " + strings.Join(assetErrors, "; ")
|
||
} else {
|
||
result.Success = true
|
||
if len(srcRel.Assets) > 0 {
|
||
result.Message = fmt.Sprintf("同步成功(含 %d 个附件)", len(srcRel.Assets))
|
||
} else {
|
||
result.Message = "同步成功"
|
||
}
|
||
}
|
||
results = append(results, result)
|
||
}
|
||
|
||
return results, nil
|
||
}
|