release: v0.4.1 - GitLab支持、迁移优化、Bug修复
Some checks failed
Release / build-windows (push) Has been cancelled
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:
420
internal/app/migration.go
Normal file
420
internal/app/migration.go
Normal file
@@ -0,0 +1,420 @@
|
||||
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
|
||||
}
|
||||
596
internal/app/online_migrate.go
Normal file
596
internal/app/online_migrate.go
Normal 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("正在迁移 %s(Gitea 原生迁移)...", 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)
|
||||
}
|
||||
1126
internal/app/platform.go
Normal file
1126
internal/app/platform.go
Normal file
File diff suppressed because it is too large
Load Diff
1244
internal/app/platform_extra.go
Normal file
1244
internal/app/platform_extra.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user