release: v0.4.1 - GitLab支持、迁移优化、Bug修复
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 方法
This commit is contained in:
zyj
2026-03-12 15:07:41 +08:00
parent c787c99622
commit 3ebcd10a23
16 changed files with 5082 additions and 64 deletions

View File

@@ -0,0 +1,596 @@
package app
import (
"bytes"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
)
// --- 在线迁移相关类型 ---
// OnlineMigrateCompareRequest 在线迁移对比请求
type OnlineMigrateCompareRequest struct {
SrcPlatform string `json:"srcPlatform"` // github / gitee / gitea
SrcBaseURL string `json:"srcBaseUrl"`
SrcUsername string `json:"srcUsername"`
SrcToken string `json:"srcToken"`
TgtPlatform string `json:"tgtPlatform"`
TgtBaseURL string `json:"tgtBaseUrl"`
TgtUsername string `json:"tgtUsername"`
TgtToken string `json:"tgtToken"`
}
// MigrateOptions 迁移选项
type MigrateOptions struct {
Labels bool `json:"labels"`
Issues bool `json:"issues"`
PullRequests bool `json:"pullRequests"`
Releases bool `json:"releases"`
Milestones bool `json:"milestones"`
}
// OnlineMigrateCompareResult 在线迁移对比结果
type OnlineMigrateCompareResult struct {
SourceRepos []RepoInfo `json:"sourceRepos"` // 源平台所有仓库
TargetRepos []RepoInfo `json:"targetRepos"` // 目标平台所有仓库
MissingRepos []RepoInfo `json:"missingRepos"` // 目标平台缺少的仓库
}
// OnlineMigrateItemResult 单个仓库在线迁移结果
type OnlineMigrateItemResult struct {
Name string `json:"name"`
CloneURL string `json:"cloneUrl,omitempty"`
Success bool `json:"success"`
Message string `json:"message"`
}
// OnlineMigrateProgress 迁移进度事件数据
type OnlineMigrateProgress struct {
Current int `json:"current"` // 当前正在处理第几个仓库(从 1 开始)
Total int `json:"total"` // 仓库总数
RepoName string `json:"repoName"` // 当前仓库名
Phase string `json:"phase"` // clone | create | push | labels | milestones | issues | releases | pullRequests | done | error
Percent float64 `json:"percent"` // 整体进度百分比 0-100
Message string `json:"message"` // 可读状态描述
}
// emitMigrateProgress 向前端发送迁移进度
func (s *AppService) emitMigrateProgress(current, total int, repoName, phase, message string) {
pct := float64(0)
if total > 0 {
phaseWeights := map[string]float64{
"clone": 0.3,
"create": 0.05,
"push": 0.3,
"labels": 0.05,
"milestones": 0.05,
"issues": 0.1,
"releases": 0.1,
"pullRequests": 0.05,
"done": 1.0,
"error": 0.0,
}
w := phaseWeights[phase]
pct = (float64(current-1) + w) / float64(total) * 100
if pct > 100 {
pct = 100
}
}
if s.app != nil {
s.app.Event.Emit("online-migrate-progress", OnlineMigrateProgress{
Current: current,
Total: total,
RepoName: repoName,
Phase: phase,
Percent: pct,
Message: message,
})
}
}
// --- 在线迁移服务方法 ---
// OnlineMigrateCompare 对比两个平台的仓库列表
func (s *AppService) OnlineMigrateCompare(
srcPlatform, srcBaseURL, srcUsername, srcToken,
tgtPlatform, tgtBaseURL, tgtUsername, tgtToken string,
) (*OnlineMigrateCompareResult, error) {
srcAPI, err := NewPlatformAPI(srcPlatform, srcBaseURL, srcUsername, srcToken)
if err != nil {
return nil, fmt.Errorf("创建源平台 API 失败: %w", err)
}
tgtAPI, err := NewPlatformAPI(tgtPlatform, tgtBaseURL, tgtUsername, tgtToken)
if err != nil {
return nil, fmt.Errorf("创建目标平台 API 失败: %w", err)
}
srcRepos, err := srcAPI.ListRepos()
if err != nil {
return nil, fmt.Errorf("获取源平台仓库列表失败: %w", err)
}
tgtRepos, err := tgtAPI.ListRepos()
if err != nil {
return nil, fmt.Errorf("获取目标平台仓库列表失败: %w", err)
}
tgtNames := make(map[string]bool)
for _, r := range tgtRepos {
tgtNames[strings.ToLower(r.Name)] = true
}
var missing []RepoInfo
for _, r := range srcRepos {
if !tgtNames[strings.ToLower(r.Name)] {
missing = append(missing, r)
}
}
return &OnlineMigrateCompareResult{
SourceRepos: srcRepos,
TargetRepos: tgtRepos,
MissingRepos: missing,
}, nil
}
// OnlineMigrateExecute 执行在线迁移
func (s *AppService) OnlineMigrateExecute(
srcPlatform, srcBaseURL, srcUsername, srcToken string,
tgtPlatform, tgtBaseURL, tgtUsername, tgtToken string,
repoNames []string,
srcUseProxy, tgtUseProxy bool,
opts MigrateOptions,
) []OnlineMigrateItemResult {
total := len(repoNames)
srcAPI, err := NewPlatformAPI(srcPlatform, srcBaseURL, srcUsername, srcToken)
if err != nil {
return []OnlineMigrateItemResult{{Name: "(全局)", Message: fmt.Sprintf("创建源平台 API 失败: %v", err)}}
}
tgtAPI, err := NewPlatformAPI(tgtPlatform, tgtBaseURL, tgtUsername, tgtToken)
if err != nil {
return []OnlineMigrateItemResult{{Name: "(全局)", Message: fmt.Sprintf("创建目标平台 API 失败: %v", err)}}
}
srcRepos, err := srcAPI.ListRepos()
if err != nil {
return []OnlineMigrateItemResult{{Name: "(全局)", Message: fmt.Sprintf("获取源平台仓库列表失败: %v", err)}}
}
repoMap := make(map[string]RepoInfo)
for _, r := range srcRepos {
repoMap[strings.ToLower(r.Name)] = r
}
// 检查目标是否是 Gitea可以用原生迁移 API
// 但如果源平台需要代理,说明 Gitea 服务器可能无法直接访问源平台(如国内服务器访问 GitHub
// 此时应跳过原生迁移,改用本地中转(本地代理克隆 → 推送到 Gitea
giteaTgt, isGiteaTarget := tgtAPI.(*GiteaAPI)
useNativeGiteaMigrate := isGiteaTarget && !srcUseProxy
// 创建临时目录(非原生 Gitea 迁移时使用)
var tmpDir string
if !useNativeGiteaMigrate {
tmpDir, err = os.MkdirTemp("", "gitpilot-migrate-*")
if err != nil {
return []OnlineMigrateItemResult{{Name: "(全局)", Message: fmt.Sprintf("创建临时目录失败: %v", err)}}
}
defer os.RemoveAll(tmpDir)
}
srcProxy := srcUseProxy
tgtProxy := tgtUseProxy
var results []OnlineMigrateItemResult
for idx, name := range repoNames {
current := idx + 1
result := OnlineMigrateItemResult{Name: name}
srcRepo, ok := repoMap[strings.ToLower(name)]
if !ok {
result.Message = "在源平台未找到该仓库"
s.emitMigrateProgress(current, total, name, "error", result.Message)
results = append(results, result)
continue
}
cloneURL := srcRepo.CloneURL
if cloneURL == "" {
result.Message = "源仓库无 clone URL"
s.emitMigrateProgress(current, total, name, "error", result.Message)
results = append(results, result)
continue
}
// 解析源仓库的 owner/repo
srcOwner, srcRepoName, parseErr := ParseOwnerRepo(cloneURL)
if parseErr != nil {
srcOwner = srcUsername
srcRepoName = name
}
// --- Gitea 目标且源不需代理: 使用原生迁移 API ---
if useNativeGiteaMigrate {
s.emitMigrateProgress(current, total, name, "clone", fmt.Sprintf("正在迁移 %sGitea 原生迁移)...", name))
targetCloneURL, migrateErr := giteaTgt.MigrateRepo(cloneURL, name, srcToken, srcPlatform, opts)
if migrateErr != nil {
result.Message = fmt.Sprintf("Gitea 迁移失败: %v", migrateErr)
s.emitMigrateProgress(current, total, name, "error", result.Message)
results = append(results, result)
continue
}
result.CloneURL = targetCloneURL
result.Success = true
result.Message = "迁移成功(含选中的标签/工单/发布/里程碑/合并请求)"
s.emitMigrateProgress(current, total, name, "done", result.Message)
results = append(results, result)
log.Printf("在线迁移(Gitea): %s 完成 → %s", name, targetCloneURL)
continue
}
// --- 非 Gitea 目标: 手动迁移 ---
authCloneURL := injectTokenToURL(cloneURL, srcUsername, srcToken, srcPlatform)
localPath := filepath.Join(tmpDir, name)
// 1. Clone
s.emitMigrateProgress(current, total, name, "clone", fmt.Sprintf("正在克隆 %s...", name))
_, err = s.gitClient.CloneWithProxy(authCloneURL, localPath, &srcProxy)
if err != nil {
result.Message = fmt.Sprintf("克隆失败: %v", err)
s.emitMigrateProgress(current, total, name, "error", result.Message)
results = append(results, result)
continue
}
// 2. 创建目标仓库
s.emitMigrateProgress(current, total, name, "create", fmt.Sprintf("正在创建目标仓库 %s...", name))
targetCloneURL, err := tgtAPI.CreateRepo(name, srcRepo.Description, srcRepo.Private)
if err != nil {
result.Message = fmt.Sprintf("创建目标仓库失败: %v", err)
s.emitMigrateProgress(current, total, name, "error", result.Message)
results = append(results, result)
continue
}
result.CloneURL = targetCloneURL
tgtOwner, tgtRepoName, tgtParseErr := ParseOwnerRepo(targetCloneURL)
if tgtParseErr != nil {
tgtOwner = tgtUsername
tgtRepoName = name
}
pushURL := injectTokenToURL(targetCloneURL, tgtUsername, tgtToken, tgtPlatform)
// 3. 添加 remote
_, err = s.gitClient.Run(localPath, "remote", "add", "target", pushURL)
if err != nil {
result.Message = fmt.Sprintf("添加 remote 失败: %v", err)
s.emitMigrateProgress(current, total, name, "error", result.Message)
results = append(results, result)
continue
}
// 4. Push 分支和标签
s.emitMigrateProgress(current, total, name, "push", fmt.Sprintf("正在推送 %s 的分支和标签...", name))
pushFailed := false
// 先尝试 --all 一次性推送
_, err = s.gitClient.RunWithProxyTimeout(localPath, &tgtProxy, 10*time.Minute,
"-c", "http.postBuffer=524288000", "push", "target", "--all")
if err != nil {
// --all 推送失败(可能 HTTP 413改为逐分支推送
log.Printf("push --all 失败,改为逐分支推送: %v", err)
s.emitMigrateProgress(current, total, name, "push", fmt.Sprintf("正在逐分支推送 %s...", name))
branchOut, brErr := s.gitClient.Run(localPath, "branch", "-a", "--format=%(refname:short)")
if brErr != nil {
result.Message = fmt.Sprintf("获取分支列表失败: %v", brErr)
s.emitMigrateProgress(current, total, name, "error", result.Message)
results = append(results, result)
continue
}
branches := strings.Split(strings.TrimSpace(branchOut), "\n")
var pushErrs []string
for _, br := range branches {
br = strings.TrimSpace(br)
if br == "" || strings.HasPrefix(br, "origin/HEAD") {
continue
}
// 将 origin/xxx 转换为本地 refspec
refspec := br
if strings.HasPrefix(br, "origin/") {
refspec = "refs/remotes/" + br + ":refs/heads/" + strings.TrimPrefix(br, "origin/")
}
_, pushErr := s.gitClient.RunWithProxyTimeout(localPath, &tgtProxy, 10*time.Minute,
"-c", "http.postBuffer=524288000", "push", "target", refspec)
if pushErr != nil {
pushErrs = append(pushErrs, fmt.Sprintf("%s(%v)", br, pushErr))
}
}
if len(pushErrs) > 0 {
// 如果所有分支都失败,才算整体失败
if len(pushErrs) == len(branches) {
result.Message = fmt.Sprintf("推送分支全部失败: %s", strings.Join(pushErrs, "; "))
s.emitMigrateProgress(current, total, name, "error", result.Message)
results = append(results, result)
pushFailed = true
} else {
log.Printf("部分分支推送失败: %s", strings.Join(pushErrs, "; "))
}
}
}
if pushFailed {
continue
}
// 推送标签
s.gitClient.RunWithProxyTimeout(localPath, &tgtProxy, 10*time.Minute,
"-c", "http.postBuffer=524288000", "push", "target", "--tags")
// --- 迁移扩展数据 ---
var migrateNotes []string
// 5. 标签
if opts.Labels {
s.emitMigrateProgress(current, total, name, "labels", fmt.Sprintf("正在迁移 %s 的标签...", name))
if labErr := s.migrateLabels(srcAPI, tgtAPI, srcOwner, srcRepoName, tgtOwner, tgtRepoName); labErr != nil {
migrateNotes = append(migrateNotes, fmt.Sprintf("标签: %v", labErr))
} else {
migrateNotes = append(migrateNotes, "标签 ✓")
}
}
// 6. 里程碑
var milestoneMap map[string]int64
if opts.Milestones {
s.emitMigrateProgress(current, total, name, "milestones", fmt.Sprintf("正在迁移 %s 的里程碑...", name))
var msErr error
milestoneMap, msErr = s.migrateMilestones(srcAPI, tgtAPI, srcOwner, srcRepoName, tgtOwner, tgtRepoName)
if msErr != nil {
migrateNotes = append(migrateNotes, fmt.Sprintf("里程碑: %v", msErr))
} else {
migrateNotes = append(migrateNotes, "里程碑 ✓")
}
}
// 7. 工单
if opts.Issues {
s.emitMigrateProgress(current, total, name, "issues", fmt.Sprintf("正在迁移 %s 的工单...", name))
if issErr := s.migrateIssues(srcAPI, tgtAPI, srcOwner, srcRepoName, tgtOwner, tgtRepoName, milestoneMap); issErr != nil {
migrateNotes = append(migrateNotes, fmt.Sprintf("工单: %v", issErr))
} else {
migrateNotes = append(migrateNotes, "工单 ✓")
}
}
// 8. 发布
if opts.Releases {
s.emitMigrateProgress(current, total, name, "releases", fmt.Sprintf("正在迁移 %s 的发布...", name))
if relErr := s.migrateReleases(srcAPI, tgtAPI, srcOwner, srcRepoName, tgtOwner, tgtRepoName, srcToken, srcPlatform); relErr != nil {
migrateNotes = append(migrateNotes, fmt.Sprintf("发布: %v", relErr))
} else {
migrateNotes = append(migrateNotes, "发布 ✓")
}
}
// 9. 合并请求(以 Issue 方式记录)
if opts.PullRequests {
s.emitMigrateProgress(current, total, name, "pullRequests", fmt.Sprintf("正在迁移 %s 的合并请求...", name))
if prErr := s.migratePullRequests(srcAPI, tgtAPI, srcOwner, srcRepoName, tgtOwner, tgtRepoName, milestoneMap); prErr != nil {
migrateNotes = append(migrateNotes, fmt.Sprintf("合并请求: %v", prErr))
} else {
migrateNotes = append(migrateNotes, "合并请求 ✓")
}
}
result.Success = true
if len(migrateNotes) > 0 {
result.Message = "迁移完成 | " + strings.Join(migrateNotes, " | ")
} else {
result.Message = "迁移成功"
}
s.emitMigrateProgress(current, total, name, "done", result.Message)
results = append(results, result)
log.Printf("在线迁移: %s 完成 → %s", name, targetCloneURL)
os.RemoveAll(localPath)
}
return results
}
// --- 迁移子步骤 ---
func (s *AppService) migrateLabels(srcAPI, tgtAPI PlatformAPI, srcOwner, srcRepo, tgtOwner, tgtRepo string) error {
srcLabels, err := srcAPI.ListLabels(srcOwner, srcRepo)
if err != nil {
return fmt.Errorf("获取源标签失败: %w", err)
}
if len(srcLabels) == 0 {
return nil
}
tgtLabels, _ := tgtAPI.ListLabels(tgtOwner, tgtRepo)
existing := make(map[string]bool)
for _, l := range tgtLabels {
existing[strings.ToLower(l.Name)] = true
}
var errs []string
for _, label := range srcLabels {
if existing[strings.ToLower(label.Name)] {
continue
}
if err := tgtAPI.CreateLabel(tgtOwner, tgtRepo, label); err != nil {
errs = append(errs, fmt.Sprintf("%s(%v)", label.Name, err))
}
}
if len(errs) > 0 {
return fmt.Errorf("部分失败: %s", strings.Join(errs, "; "))
}
return nil
}
func (s *AppService) migrateMilestones(srcAPI, tgtAPI PlatformAPI, srcOwner, srcRepo, tgtOwner, tgtRepo string) (map[string]int64, error) {
msMap := make(map[string]int64)
srcMS, err := srcAPI.ListMilestones(srcOwner, srcRepo)
if err != nil {
return msMap, fmt.Errorf("获取源里程碑失败: %w", err)
}
if len(srcMS) == 0 {
return msMap, nil
}
tgtMS, _ := tgtAPI.ListMilestones(tgtOwner, tgtRepo)
for _, ms := range tgtMS {
msMap[ms.Title] = ms.ID
}
var errs []string
for _, ms := range srcMS {
if _, exists := msMap[ms.Title]; exists {
continue
}
created, err := tgtAPI.CreateMilestone(tgtOwner, tgtRepo, ms)
if err != nil {
errs = append(errs, fmt.Sprintf("%s(%v)", ms.Title, err))
} else if created != nil {
msMap[created.Title] = created.ID
}
}
if len(errs) > 0 {
return msMap, fmt.Errorf("部分失败: %s", strings.Join(errs, "; "))
}
return msMap, nil
}
func (s *AppService) migrateIssues(srcAPI, tgtAPI PlatformAPI, srcOwner, srcRepo, tgtOwner, tgtRepo string, milestoneMap map[string]int64) error {
srcIssues, err := srcAPI.ListIssues(srcOwner, srcRepo)
if err != nil {
return fmt.Errorf("获取源工单失败: %w", err)
}
if len(srcIssues) == 0 {
return nil
}
if milestoneMap == nil {
milestoneMap = make(map[string]int64)
}
var errs []string
for _, issue := range srcIssues {
if err := tgtAPI.CreateIssue(tgtOwner, tgtRepo, issue, milestoneMap); err != nil {
errs = append(errs, fmt.Sprintf("#%d(%v)", issue.Number, err))
if len(errs) >= 5 {
errs = append(errs, "...更多错误已省略")
break
}
}
}
if len(errs) > 0 {
return fmt.Errorf("部分失败: %s", strings.Join(errs, "; "))
}
return nil
}
func (s *AppService) migrateReleases(srcAPI, tgtAPI PlatformAPI, srcOwner, srcRepo, tgtOwner, tgtRepo, srcToken, srcPlatform string) error {
srcRel, err := srcAPI.ListReleases(srcOwner, srcRepo)
if err != nil {
return fmt.Errorf("获取源发布失败: %w", err)
}
if len(srcRel) == 0 {
return nil
}
tgtRel, _ := tgtAPI.ListReleases(tgtOwner, tgtRepo)
existing := make(map[string]bool)
for _, r := range tgtRel {
existing[r.TagName] = true
}
var errs []string
for _, rel := range srcRel {
if existing[rel.TagName] {
continue
}
created, err := tgtAPI.CreateRelease(tgtOwner, tgtRepo, ReleaseCreate{
TagName: rel.TagName, Name: rel.Name, Body: rel.Body, Draft: rel.Draft, Prerelease: rel.Prerelease,
})
if err != nil {
errs = append(errs, fmt.Sprintf("%s(%v)", rel.TagName, err))
continue
}
for _, asset := range rel.Assets {
if asset.DownloadURL == "" {
continue
}
data, dlErr := DownloadAsset(asset.DownloadURL, srcToken, srcPlatform)
if dlErr != nil {
continue
}
tgtAPI.UploadAsset(tgtOwner, tgtRepo, created.ID, asset.Name, bytes.NewReader(data))
}
}
if len(errs) > 0 {
return fmt.Errorf("部分失败: %s", strings.Join(errs, "; "))
}
return nil
}
func (s *AppService) migratePullRequests(srcAPI, tgtAPI PlatformAPI, srcOwner, srcRepo, tgtOwner, tgtRepo string, milestoneMap map[string]int64) error {
srcPRs, err := srcAPI.ListPullRequests(srcOwner, srcRepo)
if err != nil {
return fmt.Errorf("获取源合并请求失败: %w", err)
}
if len(srcPRs) == 0 {
return nil
}
if milestoneMap == nil {
milestoneMap = make(map[string]int64)
}
var errs []string
for _, pr := range srcPRs {
stateEmoji := "🟢"
if pr.State == "closed" {
stateEmoji = "🔴"
} else if pr.State == "merged" {
stateEmoji = "🟣"
}
body := fmt.Sprintf(
"%s **[迁移的合并请求]** `%s` → `%s` | 状态: %s %s | 作者: @%s\n\n---\n\n%s",
stateEmoji, pr.Head, pr.Base, stateEmoji, pr.State, pr.User, pr.Body,
)
issue := IssueInfo{
Title: fmt.Sprintf("[PR#%d] %s", pr.Number, pr.Title),
Body: body,
State: "closed",
}
if err := tgtAPI.CreateIssue(tgtOwner, tgtRepo, issue, milestoneMap); err != nil {
errs = append(errs, fmt.Sprintf("PR#%d(%v)", pr.Number, err))
if len(errs) >= 5 {
errs = append(errs, "...更多错误已省略")
break
}
}
}
if len(errs) > 0 {
return fmt.Errorf("部分失败: %s", strings.Join(errs, "; "))
}
return nil
}
// injectTokenToURL 将 token 注入到 HTTPS clone URL 中用于认证
func injectTokenToURL(cloneURL, username, token, platform string) string {
if token == "" {
return cloneURL
}
if !strings.HasPrefix(cloneURL, "https://") && !strings.HasPrefix(cloneURL, "http://") {
return cloneURL
}
idx := strings.Index(cloneURL, "://")
if idx < 0 {
return cloneURL
}
scheme := cloneURL[:idx+3]
rest := cloneURL[idx+3:]
return fmt.Sprintf("%s%s:%s@%s", scheme, username, token, rest)
}