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

@@ -1,5 +1,28 @@
# Changelog # Changelog
## v0.4.1 (2026-03-12)
### ✨ 新功能
- **GitLab 平台支持**:新增 GitLab 作为第四个平台GitHub / Gitee / Gitea / GitLab
- 完整实现 PlatformAPI 全部 16 个接口方法CreateRepo / ListRepos / ListReleases / CreateRelease / UploadAsset 等)
- 实现 7 个扩展迁移方法Labels / Milestones / Issues / PullRequests
- 前端新增 GitLab 图标、平台选择项、BaseURL 输入支持
- **Release 同步 Token 输入**:同步发布时可手动输入源/目标 Token不再依赖配置文件中的凭证
### 🐛 Bug 修复
- **凭证匹配修复**自建平台Gitea/GitLab优先按 BaseURL 精确匹配,解决自建 GitLab 被误识别为 Gitea 的问题
- **Gitea 迁移 API 修复**`service` 字段从整数改为字符串,修复 HTTP 422 错误
- **配置文件覆盖修复**:构建时仅在 `bin/config.yaml` 不存在时复制初始模板,避免每次 `wails3 dev` 丢失已有配置
- **错误信息优化**:迁移相关错误信息显示实际 URL 而非推断的平台名
### 🔨 改进
- **智能迁移路径**:源平台开启代理时自动跳过 Gitea 原生迁移,改用本地代理中转(解决国内服务器无法访问 GitHub 的问题)
- **推送超时优化**:在线迁移的 push 操作超时从 30 秒提升到 10 分钟
- **大仓库推送**:设置 `http.postBuffer=500MB`push --all 失败时自动回退为逐分支推送,避免 HTTP 413
- **Git 操作**:新增 `RunWithProxyTimeout` 方法,支持自定义超时的 git 命令执行
---
## v0.4.0 (2026-03-12) ## v0.4.0 (2026-03-12)
### 🔧 架构重构 ### 🔧 架构重构

View File

@@ -9,7 +9,7 @@ info:
description: "Git Repository Management Tool" description: "Git Repository Management Tool"
copyright: "(c) 2025, GitPilot" copyright: "(c) 2025, GitPilot"
comments: "GitPilot - Git Repository Manager" comments: "GitPilot - Git Repository Manager"
version: "0.4.0" version: "0.4.1"
dev_mode: dev_mode:
root_path: . root_path: .

View File

@@ -10,11 +10,11 @@
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>com.gitpilot.app</string> <string>com.gitpilot.app</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>0.4.0</string> <string>0.4.1</string>
<key>CFBundleGetInfoString</key> <key>CFBundleGetInfoString</key>
<string>Git Repository Management Tool</string> <string>Git Repository Management Tool</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.4.0</string> <string>0.4.1</string>
<key>CFBundleIconFile</key> <key>CFBundleIconFile</key>
<string>icons</string> <string>icons</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>

View File

@@ -18,9 +18,9 @@ tasks:
cmds: cmds:
- task: generate:syso - task: generate:syso
- go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}.exe - go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}.exe
- cmd: powershell Copy-Item config.yaml -Destination {{.BIN_DIR}}/config.yaml -Force - cmd: powershell -Command "if (-not (Test-Path '{{.BIN_DIR}}/config.yaml')) { Copy-Item config.yaml -Destination '{{.BIN_DIR}}/config.yaml' }"
platforms: [windows] platforms: [windows]
- cmd: cp config.yaml {{.BIN_DIR}}/config.yaml - cmd: test -f {{.BIN_DIR}}/config.yaml || cp config.yaml {{.BIN_DIR}}/config.yaml
platforms: [linux, darwin] platforms: [linux, darwin]
- cmd: powershell Remove-item *.syso - cmd: powershell Remove-item *.syso
platforms: [windows] platforms: [windows]

View File

@@ -1,10 +1,10 @@
{ {
"fixed": { "fixed": {
"file_version": "0.4.0" "file_version": "0.4.1"
}, },
"info": { "info": {
"0000": { "0000": {
"ProductVersion": "0.4.0", "ProductVersion": "0.4.1",
"CompanyName": "GitPilot", "CompanyName": "GitPilot",
"FileDescription": "Git Repository Management Tool", "FileDescription": "Git Repository Management Tool",
"LegalCopyright": "© 2025, GitPilot", "LegalCopyright": "© 2025, GitPilot",

View File

@@ -15,8 +15,8 @@ credentials:
- platform: gitee - platform: gitee
username: zhuyuj username: zhuyuj
- platform: gitea - platform: gitea
base_url: http://192.168.1.10:3000 base_url: http://localhost:3000
username: "" username: "zhuyujun"
settings: settings:
concurrency: 6 concurrency: 6
network_check: true network_check: true

View File

@@ -85,6 +85,17 @@ const newTagName = ref('')
const newTagMessage = ref('') const newTagMessage = ref('')
const createTagLoading = ref(false) const createTagLoading = ref(false)
// ---- Release 同步 ----
const releases = ref([])
const releasesLoading = ref(false)
const selectedRelease = ref(null)
const releaseSyncSource = ref('')
const releaseSyncTarget = ref('')
const releaseSyncLoading = ref(false)
const releaseSyncResults = ref([])
const releaseSrcToken = ref('')
const releaseTgtToken = ref('')
// ---- 分支管理 ---- // ---- 分支管理 ----
const newBranchName = ref('') const newBranchName = ref('')
const createBranchLoading = ref(false) const createBranchLoading = ref(false)
@@ -1256,8 +1267,57 @@ watch(activeTab, (tab) => {
if (tab === 'tags' && !tags.value.length && !tagsLoading.value) { if (tab === 'tags' && !tags.value.length && !tagsLoading.value) {
loadTags() loadTags()
} }
if (tab === 'releases' && !releases.value.length && releaseSyncSource.value) {
loadReleases()
}
}) })
// ---- Release 同步 ----
async function loadReleases() {
if (!props.project?.path || !releaseSyncSource.value) return
releasesLoading.value = true
selectedRelease.value = null
try {
const list = await AppService.GetRemoteReleases(props.project.path, releaseSyncSource.value, releaseSrcToken.value)
releases.value = Array.isArray(list) ? list : []
} catch (e) {
console.error('获取 Release 失败:', e)
releases.value = []
message.error('获取 Release 失败: ' + String(e))
} finally {
releasesLoading.value = false
}
}
async function doSyncReleases() {
if (!props.project?.path || !releaseSyncSource.value || !releaseSyncTarget.value) return
releaseSyncLoading.value = true
releaseSyncResults.value = []
try {
const results = await AppService.SyncReleases(props.project.path, releaseSyncSource.value, releaseSyncTarget.value, releaseSrcToken.value, releaseTgtToken.value)
releaseSyncResults.value = Array.isArray(results) ? results : []
const successCount = releaseSyncResults.value.filter(r => r.success).length
message.info(`Release 同步完成:${successCount}/${releaseSyncResults.value.length}`)
} catch (e) {
console.error('Release 同步失败:', e)
Modal.error({ title: 'Release 同步失败', content: String(e) })
} finally {
releaseSyncLoading.value = false
}
}
function formatFileSize(bytes) {
if (!bytes || bytes <= 0) return ''
const units = ['B', 'KB', 'MB', 'GB']
let i = 0
let size = bytes
while (size >= 1024 && i < units.length - 1) {
size /= 1024
i++
}
return size.toFixed(i === 0 ? 0 : 1) + ' ' + units[i]
}
// ---- Remote 远程仓库管理 ---- // ---- Remote 远程仓库管理 ----
async function switchRemote(remoteName) { async function switchRemote(remoteName) {
currentRemote.value = remoteName currentRemote.value = remoteName
@@ -1549,6 +1609,9 @@ const currentRemoteUrl = computed(() => {
<div class="file-list-tab" :class="{ active: activeTab === 'tags' }" @click="activeTab = 'tags'"> <div class="file-list-tab" :class="{ active: activeTab === 'tags' }" @click="activeTab = 'tags'">
<TagsOutlined /> 标签 <TagsOutlined /> 标签
</div> </div>
<div class="file-list-tab" :class="{ active: activeTab === 'releases' }" @click="activeTab = 'releases'">
<DownloadOutlined /> 发布
</div>
</div> </div>
<!-- ===== 变更面板 ===== --> <!-- ===== 变更面板 ===== -->
@@ -1962,6 +2025,94 @@ const currentRemoteUrl = computed(() => {
</div> </div>
</div> </div>
</template> </template>
<!-- ===== 发布同步面板 ===== -->
<template v-else-if="activeTab === 'releases'">
<div class="file-list-content tags-panel">
<!-- /目标 remote 选择 -->
<div style="padding: 8px 10px; border-bottom: 1px solid var(--border-color);">
<div style="display: flex; gap: 6px; align-items: center; margin-bottom: 6px;">
<span style="font-size: 11px; color: var(--text-muted); width: 30px; flex-shrink: 0;"></span>
<a-select v-model:value="releaseSyncSource" size="small" style="flex: 1;" placeholder="源 remote">
<a-select-option v-for="r in remotes" :key="r.name" :value="r.name">{{ r.name }}</a-select-option>
</a-select>
<a-button size="small" :loading="releasesLoading" @click="loadReleases" :disabled="!releaseSyncSource">
<template #icon><ReloadOutlined /></template>
</a-button>
</div>
<div style="display: flex; gap: 6px; align-items: center; margin-bottom: 6px;">
<span style="font-size: 11px; color: var(--text-muted); width: 30px; flex-shrink: 0;">目标</span>
<a-select v-model:value="releaseSyncTarget" size="small" style="flex: 1;" placeholder="目标 remote">
<a-select-option v-for="r in remotes" :key="r.name" :value="r.name" :disabled="r.name === releaseSyncSource">{{ r.name }}</a-select-option>
</a-select>
<a-button
size="small"
type="primary"
:loading="releaseSyncLoading"
:disabled="!releaseSyncSource || !releaseSyncTarget || releaseSyncSource === releaseSyncTarget"
@click="doSyncReleases"
>
<template #icon><SyncOutlined /></template>
同步
</a-button>
</div>
<div style="display: flex; gap: 6px; align-items: center; margin-bottom: 6px;">
<span style="font-size: 11px; color: var(--text-muted); width: 30px; flex-shrink: 0;">认证</span>
<a-input-password v-model:value="releaseSrcToken" size="small" style="flex: 1;" placeholder="源 Token可选" />
<a-input-password v-model:value="releaseTgtToken" size="small" style="flex: 1;" placeholder="目标 Token可选" />
</div>
</div>
<!-- Release 列表 -->
<div class="tag-list-section">
<template v-if="releasesLoading">
<div v-for="i in 4" :key="i" style="padding: 10px 12px;">
<div style="height: 14px; background: var(--bg-hover, #333); border-radius: 4px; animation: pulse 1.5s infinite;" :style="{ width: (40 + i * 8) + '%' }"></div>
</div>
</template>
<div v-else-if="!releases.length && releaseSyncSource" style="padding: 24px; text-align: center; color: var(--text-muted);">
<DownloadOutlined :style="{ fontSize: '28px', marginBottom: '8px' }" />
<div>暂无 Release</div>
</div>
<div v-else-if="!releaseSyncSource" style="padding: 24px; text-align: center; color: var(--text-muted);">
<DownloadOutlined :style="{ fontSize: '28px', marginBottom: '8px' }" />
<div>选择源 remote 以查看 Release</div>
</div>
<template v-else>
<div
v-for="rel in releases"
:key="rel.tagName"
class="tag-item"
:class="{ active: selectedRelease?.tagName === rel.tagName }"
@click="selectedRelease = rel"
>
<div class="tag-main-row">
<TagOutlined class="tag-icon" />
<span class="tag-name">{{ rel.name || rel.tagName }}</span>
<a-tag v-if="rel.draft" size="small" color="orange">草稿</a-tag>
<a-tag v-if="rel.prerelease" size="small" color="blue">预发布</a-tag>
<a-badge v-if="rel.assets?.length" :count="rel.assets.length" :number-style="{ backgroundColor: 'var(--accent)', fontSize: '10px', height: '16px', lineHeight: '16px', minWidth: '16px', padding: '0 4px' }" />
</div>
<div class="tag-meta">
<span class="tag-hash">{{ rel.tagName }}</span>
<span v-if="rel.publishedAt || rel.createdAt" class="tag-time">{{ formatTime(rel.publishedAt || rel.createdAt) }}</span>
</div>
</div>
</template>
</div>
<!-- 同步结果 -->
<div v-if="releaseSyncResults.length" style="padding: 8px 10px; border-top: 1px solid var(--border-color);">
<div style="font-size: 11px; color: var(--text-muted); margin-bottom: 4px;">同步结果</div>
<div v-for="r in releaseSyncResults" :key="r.tagName" style="display: flex; align-items: center; gap: 4px; padding: 2px 0; font-size: 11px;">
<CheckCircleOutlined v-if="r.success" style="color: #a6e3a1; flex-shrink: 0;" />
<CloseCircleOutlined v-else style="color: #f38ba8; flex-shrink: 0;" />
<span style="font-weight: 500;">{{ r.tagName }}</span>
<span style="color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ r.message }}</span>
</div>
</div>
</div>
</template>
</div> </div>
<!-- 拖拽分隔条 --> <!-- 拖拽分隔条 -->
@@ -2050,6 +2201,42 @@ const currentRemoteUrl = computed(() => {
<span style="color: var(--text-muted); font-size: 12px; margin-top: 4px;"> {{ tags.length }} 个标签</span> <span style="color: var(--text-muted); font-size: 12px; margin-top: 4px;"> {{ tags.length }} 个标签</span>
</div> </div>
</template> </template>
<!-- Release 详情模式 -->
<template v-else-if="activeTab === 'releases'">
<div v-if="selectedRelease" class="release-detail-panel">
<div class="release-detail-header">
<h3 style="margin: 0; font-size: 16px;">{{ selectedRelease.name || selectedRelease.tagName }}</h3>
<div style="display: flex; gap: 6px; margin-top: 4px;">
<a-tag color="green">{{ selectedRelease.tagName }}</a-tag>
<a-tag v-if="selectedRelease.draft" color="orange">草稿</a-tag>
<a-tag v-if="selectedRelease.prerelease" color="blue">预发布</a-tag>
<span v-if="selectedRelease.publishedAt || selectedRelease.createdAt" style="color: var(--text-muted); font-size: 12px; line-height: 22px;">
{{ formatTime(selectedRelease.publishedAt || selectedRelease.createdAt) }}
</span>
</div>
</div>
<div v-if="selectedRelease.body" class="release-detail-body">
<pre style="white-space: pre-wrap; font-size: 13px; line-height: 1.6; margin: 0; font-family: inherit;">{{ selectedRelease.body }}</pre>
</div>
<div v-if="selectedRelease.assets?.length" class="release-detail-assets">
<div style="font-weight: 500; font-size: 13px; margin-bottom: 8px;">附件 ({{ selectedRelease.assets.length }})</div>
<div
v-for="asset in selectedRelease.assets"
:key="asset.name"
class="release-asset-item"
>
<DownloadOutlined style="color: var(--accent); flex-shrink: 0;" />
<span style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ asset.name }}</span>
<span style="color: var(--text-muted); font-size: 11px; flex-shrink: 0;">{{ formatFileSize(asset.size) }}</span>
</div>
</div>
</div>
<div v-else class="empty-state small">
<DownloadOutlined :style="{ fontSize: '48px', color: 'var(--text-muted)', marginBottom: '8px' }" />
<span style="color: var(--text-muted)">选择一个 Release 查看详情</span>
<span style="color: var(--text-muted); font-size: 12px; margin-top: 4px;"> {{ releases.length }} Release</span>
</div>
</template>
</div> </div>
</div> </div>
</template> </template>
@@ -3059,4 +3246,43 @@ const currentRemoteUrl = computed(() => {
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
flex-shrink: 0; flex-shrink: 0;
} }
/* Release 详情面板 */
.release-detail-panel {
padding: 16px;
overflow-y: auto;
height: 100%;
}
.release-detail-header {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-color);
}
.release-detail-body {
margin-bottom: 16px;
padding: 12px;
background: var(--bg-secondary, #181825);
border-radius: 6px;
border: 1px solid var(--border-color);
}
.release-detail-assets {
padding-top: 12px;
border-top: 1px solid var(--border-color);
}
.release-asset-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
font-size: 13px;
}
.release-asset-item:hover {
background: var(--bg-hover, #313244);
}
</style> </style>

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, onMounted, computed, h } from 'vue' import { ref, onMounted, onBeforeUnmount, computed, h } from 'vue'
import { import {
FolderOutlined, FolderOutlined,
FolderOpenOutlined, FolderOpenOutlined,
@@ -17,8 +17,11 @@ import {
KeyOutlined, KeyOutlined,
GroupOutlined, GroupOutlined,
SwapOutlined, SwapOutlined,
ExportOutlined,
CloudSyncOutlined,
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
import { message, Modal } from 'ant-design-vue' import { message, Modal } from 'ant-design-vue'
import { Events } from '@wailsio/runtime'
import { AppService } from '../../bindings/github.com/zhuy1228/GitPilot/internal/app' import { AppService } from '../../bindings/github.com/zhuy1228/GitPilot/internal/app'
const props = defineProps({ const props = defineProps({
@@ -70,6 +73,34 @@ const batchResultMap = computed(() => {
// 右键菜单 // 右键菜单
const contextMenu = ref({ visible: false, x: 0, y: 0, type: '', data: {} }) const contextMenu = ref({ visible: false, x: 0, y: 0, type: '', data: {} })
// ---- 迁移操作 ----
const showMigrateModal = ref(false)
const migrateLoading = ref(false)
const migrateForm = ref({ targetPlatform: '', targetUsername: '', description: '', private: false })
const selectedMigratePaths = ref([])
const migrateResults = ref([])
const migrateCredentials = ref([])
const migrateResultMap = computed(() => {
const map = {}
migrateResults.value.forEach(r => { map[r.path] = r })
return map
})
// ---- 在线迁移 ----
const showOnlineMigrateModal = ref(false)
const onlineMigrateStep = ref('form') // form | compare | execute
const onlineMigrateSrc = ref({ platform: '', baseUrl: '', username: '', token: '', useProxy: false })
const onlineMigrateTgt = ref({ platform: '', baseUrl: '', username: '', token: '', useProxy: false })
const onlineCompareResult = ref({ sourceRepos: [], targetRepos: [], missingRepos: [] })
const selectedOnlineRepos = ref([])
const onlineCompareLoading = ref(false)
const onlineMigrateLoading = ref(false)
const onlineMigrateResults = ref([])
const onlineMigrateCredentials = ref([])
const migrateOpts = ref({ labels: true, issues: true, pullRequests: true, releases: true, milestones: true })
const migrateProgress = ref({ current: 0, total: 0, repoName: '', phase: '', percent: 0, message: '' })
let migrateProgressUnsubscribe = null
// 获取所有分组名(用于下拉选择) // 获取所有分组名(用于下拉选择)
const groupNames = computed(() => { const groupNames = computed(() => {
const names = [] const names = []
@@ -474,11 +505,211 @@ async function doBatchPush() {
} }
} }
// ---- 迁移功能 ----
async function openMigrateModal() {
showMigrateModal.value = true
migrateResults.value = []
migrateForm.value = { targetPlatform: '', targetUsername: '', description: '', private: false }
// 加载凭证和项目概览
try {
const creds = await AppService.GetCredentials()
migrateCredentials.value = Array.isArray(creds) ? creds.filter(c => c.token) : []
} catch (e) {
migrateCredentials.value = []
}
// 复用 batch overview
if (!batchOverviews.value.length) {
await loadBatchOverview()
}
selectedMigratePaths.value = batchOverviews.value
.filter(p => !p.error)
.map(p => p.path)
}
function toggleMigratePath(path) {
const idx = selectedMigratePaths.value.indexOf(path)
if (idx >= 0) {
selectedMigratePaths.value = selectedMigratePaths.value.filter(p => p !== path)
} else {
selectedMigratePaths.value = [...selectedMigratePaths.value, path]
}
}
function toggleAllMigratePaths(e) {
if (e.target.checked) {
selectedMigratePaths.value = batchOverviews.value
.filter(p => !p.error)
.map(p => p.path)
} else {
selectedMigratePaths.value = []
}
}
// 选择凭证后自动填充 platform + username
function onMigrateCredentialChange(val) {
const parts = val.split('/')
migrateForm.value.targetPlatform = parts[0]
migrateForm.value.targetUsername = parts.slice(1).join('/')
}
async function doMigrate() {
if (!selectedMigratePaths.value.length) {
message.warning('请选择要迁移的项目')
return
}
if (!migrateForm.value.targetPlatform || !migrateForm.value.targetUsername) {
message.warning('请选择目标凭证')
return
}
migrateLoading.value = true
try {
const results = await AppService.BatchMigrateProjects(
selectedMigratePaths.value,
migrateForm.value.targetPlatform,
migrateForm.value.targetUsername,
migrateForm.value.description,
migrateForm.value.private
)
migrateResults.value = Array.isArray(results) ? results : []
const successCount = migrateResults.value.filter(r => r.success).length
message.info(`迁移完成:${successCount}/${migrateResults.value.length} 成功`)
await loadTree()
} catch (e) {
Modal.error({ title: '迁移失败', content: String(e) })
} finally {
migrateLoading.value = false
}
}
// ---- 在线迁移功能 ----
async function openOnlineMigrateModal() {
showOnlineMigrateModal.value = true
onlineMigrateStep.value = 'form'
onlineMigrateSrc.value = { platform: '', baseUrl: '', username: '', token: '', useProxy: false }
onlineMigrateTgt.value = { platform: '', baseUrl: '', username: '', token: '', useProxy: false }
onlineCompareResult.value = { sourceRepos: [], targetRepos: [], missingRepos: [] }
selectedOnlineRepos.value = []
onlineMigrateResults.value = []
migrateProgress.value = { current: 0, total: 0, repoName: '', phase: '', percent: 0, message: '' }
// 监听迁移进度事件
if (migrateProgressUnsubscribe) migrateProgressUnsubscribe()
migrateProgressUnsubscribe = Events.On('online-migrate-progress', (event) => {
const data = event.data?.[0] || event.data
if (data) {
migrateProgress.value = data
}
})
// 加载凭证列表用于快捷选择
try {
const creds = await AppService.GetCredentials()
onlineMigrateCredentials.value = Array.isArray(creds) ? creds.filter(c => c.token) : []
} catch (e) {
onlineMigrateCredentials.value = []
}
}
function onOnlineSrcCredentialChange(val) {
const cred = onlineMigrateCredentials.value.find(c => (c.platform + '/' + c.username) === val)
if (cred) {
onlineMigrateSrc.value.platform = cred.platform
onlineMigrateSrc.value.baseUrl = cred.baseUrl || ''
onlineMigrateSrc.value.username = cred.username
onlineMigrateSrc.value.token = cred.token || ''
}
}
function onOnlineTgtCredentialChange(val) {
const cred = onlineMigrateCredentials.value.find(c => (c.platform + '/' + c.username) === val)
if (cred) {
onlineMigrateTgt.value.platform = cred.platform
onlineMigrateTgt.value.baseUrl = cred.baseUrl || ''
onlineMigrateTgt.value.username = cred.username
onlineMigrateTgt.value.token = cred.token || ''
}
}
async function doOnlineCompare() {
const src = onlineMigrateSrc.value
const tgt = onlineMigrateTgt.value
if (!src.platform || !src.username || !src.token) {
message.warning('请填写完整的源平台信息')
return
}
if (!tgt.platform || !tgt.username || !tgt.token) {
message.warning('请填写完整的目标平台信息')
return
}
onlineCompareLoading.value = true
try {
const result = await AppService.OnlineMigrateCompare(
src.platform, src.baseUrl, src.username, src.token,
tgt.platform, tgt.baseUrl, tgt.username, tgt.token
)
onlineCompareResult.value = result || { sourceRepos: [], targetRepos: [], missingRepos: [] }
// 默认全选缺少的仓库
selectedOnlineRepos.value = (onlineCompareResult.value.missingRepos || []).map(r => r.name)
onlineMigrateStep.value = 'compare'
if (!onlineCompareResult.value.missingRepos?.length) {
message.success('目标平台已拥有所有仓库,无需迁移')
}
} catch (e) {
Modal.error({ title: '对比失败', content: String(e) })
} finally {
onlineCompareLoading.value = false
}
}
function toggleOnlineRepo(name) {
const idx = selectedOnlineRepos.value.indexOf(name)
if (idx >= 0) {
selectedOnlineRepos.value = selectedOnlineRepos.value.filter(n => n !== name)
} else {
selectedOnlineRepos.value = [...selectedOnlineRepos.value, name]
}
}
function toggleAllOnlineRepos(e) {
if (e.target.checked) {
selectedOnlineRepos.value = (onlineCompareResult.value.missingRepos || []).map(r => r.name)
} else {
selectedOnlineRepos.value = []
}
}
async function doOnlineMigrateExecute() {
if (!selectedOnlineRepos.value.length) {
message.warning('请选择要迁移的仓库')
return
}
const src = onlineMigrateSrc.value
const tgt = onlineMigrateTgt.value
onlineMigrateLoading.value = true
onlineMigrateStep.value = 'execute'
try {
const results = await AppService.OnlineMigrateExecute(
src.platform, src.baseUrl, src.username, src.token,
tgt.platform, tgt.baseUrl, tgt.username, tgt.token,
selectedOnlineRepos.value,
src.useProxy,
tgt.useProxy,
migrateOpts.value
)
onlineMigrateResults.value = Array.isArray(results) ? results : []
const successCount = onlineMigrateResults.value.filter(r => r.success).length
message.info(`在线迁移完成:${successCount}/${onlineMigrateResults.value.length} 成功`)
} catch (e) {
Modal.error({ title: '在线迁移失败', content: String(e) })
} finally {
onlineMigrateLoading.value = false
}
}
// 分组图标 // 分组图标
const groupIcons = { const groupIcons = {
github: `<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>`, github: `<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>`,
gitee: `<svg viewBox="0 0 1024 1024" fill="currentColor"><path d="M512 1024C229.222 1024 0 794.778 0 512S229.222 0 512 0s512 229.222 512 512-229.222 512-512 512z m259.149-568.883h-290.74a25.293 25.293 0 0 0-25.292 25.293l-0.026 63.206c0 13.952 11.315 25.293 25.267 25.293h177.024c13.978 0 25.293 11.315 25.293 25.267v12.646a75.853 75.853 0 0 1-75.853 75.853h-240.23a25.293 25.293 0 0 1-25.267-25.293V417.203a75.853 75.853 0 0 1 75.827-75.853h353.946a25.293 25.293 0 0 0 25.267-25.292l0.077-63.207a25.293 25.293 0 0 0-25.268-25.293H417.152a189.62 189.62 0 0 0-189.62 189.645V771.15c0 13.977 11.316 25.293 25.294 25.293h372.94a170.65 170.65 0 0 0 170.65-170.65V480.384a25.293 25.293 0 0 0-25.293-25.267z"/></svg>`, gitee: `<svg viewBox="0 0 1024 1024" fill="currentColor"><path d="M512 1024C229.222 1024 0 794.778 0 512S229.222 0 512 0s512 229.222 512 512-229.222 512-512 512z m259.149-568.883h-290.74a25.293 25.293 0 0 0-25.292 25.293l-0.026 63.206c0 13.952 11.315 25.293 25.267 25.293h177.024c13.978 0 25.293 11.315 25.293 25.267v12.646a75.853 75.853 0 0 1-75.853 75.853h-240.23a25.293 25.293 0 0 1-25.267-25.293V417.203a75.853 75.853 0 0 1 75.827-75.853h353.946a25.293 25.293 0 0 0 25.267-25.292l0.077-63.207a25.293 25.293 0 0 0-25.268-25.293H417.152a189.62 189.62 0 0 0-189.62 189.645V771.15c0 13.977 11.316 25.293 25.294 25.293h372.94a170.65 170.65 0 0 0 170.65-170.65V480.384a25.293 25.293 0 0 0-25.293-25.267z"/></svg>`,
gitea: `<svg viewBox="0 0 640 640" fill="currentColor"><path d="M395.022 297.778c-13.158 0-23.822 10.664-23.822 23.822s10.664 23.822 23.822 23.822 23.822-10.664 23.822-23.822-10.664-23.822-23.822-23.822zM243.2 297.778c-13.158 0-23.822 10.664-23.822 23.822s10.664 23.822 23.822 23.822 23.822-10.664 23.822-23.822-10.664-23.822-23.822-23.822zM319.111 22.756C158.578 22.756 28.444 152.889 28.444 313.422s130.133 290.667 290.667 290.667 290.667-130.133 290.667-290.667S479.644 22.756 319.111 22.756zm165.689 361.244c0 5.333-0.711 10.667-1.778 15.644-20.267 93.867-148.089 167.111-305.067 167.111a360.604 360.604 0 0 1-65.778-5.689c-21.333 18.133-56.889 37.689-101.333 49.422-7.822 2.133-16 3.911-24.889 5.333h-0.711c-4.267 0-7.822-3.556-8.889-7.822v-0.356c-1.067-4.622 2.133-7.467 4.978-10.667 17.067-18.844 36.622-34.844 48-71.467-51.911-36.267-83.911-85.067-83.911-139.733 0-106.311 107.378-192.356 239.644-192.356 118.4 0 220.089 68.267 238.578 160.356 1.778 6.4 3.556 14.578 3.556 22.4-0.356 2.489-0.356 5.333-0.356 7.822z"/></svg>`, gitea: `<svg viewBox="0 0 640 640" fill="currentColor"><path d="M395.022 297.778c-13.158 0-23.822 10.664-23.822 23.822s10.664 23.822 23.822 23.822 23.822-10.664 23.822-23.822-10.664-23.822-23.822-23.822zM243.2 297.778c-13.158 0-23.822 10.664-23.822 23.822s10.664 23.822 23.822 23.822 23.822-10.664 23.822-23.822-10.664-23.822-23.822-23.822zM319.111 22.756C158.578 22.756 28.444 152.889 28.444 313.422s130.133 290.667 290.667 290.667 290.667-130.133 290.667-290.667S479.644 22.756 319.111 22.756zm165.689 361.244c0 5.333-0.711 10.667-1.778 15.644-20.267 93.867-148.089 167.111-305.067 167.111a360.604 360.604 0 0 1-65.778-5.689c-21.333 18.133-56.889 37.689-101.333 49.422-7.822 2.133-16 3.911-24.889 5.333h-0.711c-4.267 0-7.822-3.556-8.889-7.822v-0.356c-1.067-4.622 2.133-7.467 4.978-10.667 17.067-18.844 36.622-34.844 48-71.467-51.911-36.267-83.911-85.067-83.911-139.733 0-106.311 107.378-192.356 239.644-192.356 118.4 0 220.089 68.267 238.578 160.356 1.778 6.4 3.556 14.578 3.556 22.4-0.356 2.489-0.356 5.333-0.356 7.822z"/></svg>`,
gitlab: `<svg viewBox="0 0 512 512" fill="currentColor"><path d="M494.07 281.6l-25.18-78.08-49.71-153.87a11.68 11.68 0 0 0-22.2 0l-49.71 153.87H164.73L115.02 49.65a11.68 11.68 0 0 0-22.2 0L43.11 203.52 17.93 281.6a23.4 23.4 0 0 0 8.49 26.14L256 493.49l229.58-185.75a23.4 23.4 0 0 0 8.49-26.14z"/></svg>`,
} }
function getGroupIcon(icon) { function getGroupIcon(icon) {
@@ -488,6 +719,10 @@ function getGroupIcon(icon) {
onMounted(() => { onMounted(() => {
loadTree() loadTree()
}) })
onBeforeUnmount(() => {
if (migrateProgressUnsubscribe) migrateProgressUnsubscribe()
})
</script> </script>
<template> <template>
@@ -495,6 +730,12 @@ onMounted(() => {
<div class="sidebar-header"> <div class="sidebar-header">
<span class="logo">GitPilot</span> <span class="logo">GitPilot</span>
<a-space :size="0"> <a-space :size="0">
<a-button type="text" size="small" @click="openOnlineMigrateModal" title="在线迁移">
<template #icon><CloudSyncOutlined /></template>
</a-button>
<a-button type="text" size="small" @click="openMigrateModal" title="迁移项目">
<template #icon><ExportOutlined /></template>
</a-button>
<a-button type="text" size="small" @click="openBatchModal" title="批量操作"> <a-button type="text" size="small" @click="openBatchModal" title="批量操作">
<template #icon><AppstoreOutlined /></template> <template #icon><AppstoreOutlined /></template>
</a-button> </a-button>
@@ -650,6 +891,7 @@ onMounted(() => {
<a-select-option value="github">🐙 GitHub</a-select-option> <a-select-option value="github">🐙 GitHub</a-select-option>
<a-select-option value="gitee">🟠 Gitee</a-select-option> <a-select-option value="gitee">🟠 Gitee</a-select-option>
<a-select-option value="gitea">🍵 Gitea</a-select-option> <a-select-option value="gitea">🍵 Gitea</a-select-option>
<a-select-option value="gitlab">🦊 GitLab</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
</a-form> </a-form>
@@ -813,6 +1055,379 @@ onMounted(() => {
</div> </div>
</div> </div>
</a-modal> </a-modal>
<!-- 迁移弹窗 -->
<a-modal
v-model:open="showMigrateModal"
title="迁移项目到其他平台"
:width="640"
:footer="null"
>
<div class="batch-panel">
<!-- 目标凭证选择 -->
<div style="margin-bottom: 12px;">
<div style="margin-bottom: 6px; font-size: 12px; color: var(--text-muted);">目标平台凭证</div>
<a-select
style="width: 100%"
placeholder="选择目标平台凭证(需要 Token"
@change="onMigrateCredentialChange"
>
<a-select-option
v-for="c in migrateCredentials"
:key="c.platform + '/' + c.username"
:value="c.platform + '/' + c.username"
>
{{ c.platform }} / {{ c.username }}
<span v-if="c.baseUrl" style="color: var(--text-muted); font-size: 11px;"> ({{ c.baseUrl }})</span>
</a-select-option>
</a-select>
</div>
<div style="margin-bottom: 12px; display: flex; gap: 8px;">
<div style="flex: 1;">
<div style="margin-bottom: 4px; font-size: 12px; color: var(--text-muted);">仓库描述可选</div>
<a-input v-model:value="migrateForm.description" placeholder="仓库描述" size="small" />
</div>
<div style="display: flex; align-items: flex-end;">
<a-checkbox v-model:checked="migrateForm.private">私有</a-checkbox>
</div>
</div>
<!-- 项目列表 -->
<div class="batch-header">
<a-checkbox
:checked="selectedMigratePaths.length === batchOverviews.filter(p => !p.error).length && batchOverviews.length > 0"
:indeterminate="selectedMigratePaths.length > 0 && selectedMigratePaths.length < batchOverviews.filter(p => !p.error).length"
@change="toggleAllMigratePaths"
>
全选 ({{ selectedMigratePaths.length }}/{{ batchOverviews.filter(p => !p.error).length }})
</a-checkbox>
<span style="flex:1"></span>
<a-button
type="primary"
size="small"
:loading="migrateLoading"
:disabled="!selectedMigratePaths.length || !migrateForm.targetPlatform"
@click="doMigrate"
>
<template #icon><ExportOutlined /></template>
开始迁移
</a-button>
</div>
<div class="batch-list">
<div v-if="!batchOverviews.length" style="padding: 24px; text-align: center; color: var(--text-muted);">
暂无项目
</div>
<template v-else>
<div
v-for="proj in batchOverviews"
:key="'mig-' + proj.key"
class="batch-item"
:class="{ error: !!proj.error }"
>
<a-checkbox
:checked="selectedMigratePaths.includes(proj.path)"
:disabled="!!proj.error"
@change="toggleMigratePath(proj.path)"
style="flex-shrink: 0;"
/>
<div class="batch-item-info">
<div class="batch-item-name">{{ proj.name }}</div>
<div class="batch-item-meta">
<a-tag v-if="proj.branch" size="small" color="green">
<BranchesOutlined /> {{ proj.branch }}
</a-tag>
<span v-if="proj.error" style="color: #f38ba8; font-size: 11px;">{{ proj.error }}</span>
</div>
</div>
<div v-if="migrateResultMap[proj.path]" class="batch-result-icon">
<CheckCircleOutlined v-if="migrateResultMap[proj.path].success" style="color: #a6e3a1;" />
<a-tooltip v-else :title="migrateResultMap[proj.path].message">
<CloseCircleOutlined style="color: #f38ba8;" />
</a-tooltip>
</div>
</div>
</template>
</div>
<!-- 迁移结果 -->
<div v-if="migrateResults.length" style="margin-top: 12px; border-top: 1px solid var(--border-color); padding-top: 8px;">
<div style="font-size: 12px; color: var(--text-muted); margin-bottom: 4px;">迁移结果</div>
<div v-for="r in migrateResults" :key="r.path" style="display: flex; align-items: center; gap: 6px; padding: 3px 0; font-size: 12px;">
<CheckCircleOutlined v-if="r.success" style="color: #a6e3a1;" />
<CloseCircleOutlined v-else style="color: #f38ba8;" />
<span>{{ r.name || r.path }}</span>
<span style="color: var(--text-muted);">{{ r.message }}</span>
<a v-if="r.cloneUrl" :href="r.cloneUrl" target="_blank" style="font-size: 11px; color: var(--accent);">{{ r.cloneUrl }}</a>
</div>
</div>
</div>
</a-modal>
<!-- 在线迁移弹窗 -->
<a-modal
v-model:open="showOnlineMigrateModal"
title="在线迁移"
:width="720"
:footer="null"
:maskClosable="!onlineCompareLoading && !onlineMigrateLoading"
:closable="!onlineCompareLoading && !onlineMigrateLoading"
>
<!-- 步骤条 -->
<a-steps :current="onlineMigrateStep === 'form' ? 0 : onlineMigrateStep === 'compare' ? 1 : 2" size="small" style="margin-bottom: 16px;">
<a-step title="配置平台" />
<a-step title="对比仓库" />
<a-step title="迁移结果" />
</a-steps>
<!-- Step 1: 配置平台 -->
<div v-if="onlineMigrateStep === 'form'">
<div class="online-migrate-platforms">
<!-- 源平台 -->
<div class="online-migrate-platform-card">
<div class="platform-card-title">📤 源平台</div>
<a-form layout="vertical" size="small">
<a-form-item label="快捷选择凭证">
<a-select
placeholder="选择已保存的凭证"
allow-clear
@change="onOnlineSrcCredentialChange"
style="width: 100%"
>
<a-select-option
v-for="c in onlineMigrateCredentials"
:key="'src-' + c.platform + '/' + c.username"
:value="c.platform + '/' + c.username"
>
{{ c.platform }} / {{ c.username }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="平台" required>
<a-select v-model:value="onlineMigrateSrc.platform" placeholder="选择平台">
<a-select-option value="github">GitHub</a-select-option>
<a-select-option value="gitee">Gitee</a-select-option>
<a-select-option value="gitea">Gitea</a-select-option>
<a-select-option value="gitlab">GitLab</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="onlineMigrateSrc.platform === 'gitea' || onlineMigrateSrc.platform === 'gitlab'" label="Base URL" required>
<a-input v-model:value="onlineMigrateSrc.baseUrl" placeholder="http://192.168.1.10:3000" />
</a-form-item>
<a-form-item label="用户名" required>
<a-input v-model:value="onlineMigrateSrc.username" placeholder="用户名" />
</a-form-item>
<a-form-item label="Token" required>
<a-input-password v-model:value="onlineMigrateSrc.token" placeholder="API Token" />
</a-form-item>
<a-form-item>
<a-checkbox v-model:checked="onlineMigrateSrc.useProxy">使用代理</a-checkbox>
</a-form-item>
</a-form>
</div>
<!-- 箭头 -->
<div class="online-migrate-arrow">
<SwapOutlined style="font-size: 24px; color: var(--accent);" />
</div>
<!-- 目标平台 -->
<div class="online-migrate-platform-card">
<div class="platform-card-title">📥 目标平台</div>
<a-form layout="vertical" size="small">
<a-form-item label="快捷选择凭证">
<a-select
placeholder="选择已保存的凭证"
allow-clear
@change="onOnlineTgtCredentialChange"
style="width: 100%"
>
<a-select-option
v-for="c in onlineMigrateCredentials"
:key="'tgt-' + c.platform + '/' + c.username"
:value="c.platform + '/' + c.username"
>
{{ c.platform }} / {{ c.username }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="平台" required>
<a-select v-model:value="onlineMigrateTgt.platform" placeholder="选择平台">
<a-select-option value="github">GitHub</a-select-option>
<a-select-option value="gitee">Gitee</a-select-option>
<a-select-option value="gitea">Gitea</a-select-option>
<a-select-option value="gitlab">GitLab</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="onlineMigrateTgt.platform === 'gitea' || onlineMigrateTgt.platform === 'gitlab'" label="Base URL" required>
<a-input v-model:value="onlineMigrateTgt.baseUrl" placeholder="http://192.168.1.10:3000" />
</a-form-item>
<a-form-item label="用户名" required>
<a-input v-model:value="onlineMigrateTgt.username" placeholder="用户名" />
</a-form-item>
<a-form-item label="Token" required>
<a-input-password v-model:value="onlineMigrateTgt.token" placeholder="API Token" />
</a-form-item>
<a-form-item>
<a-checkbox v-model:checked="onlineMigrateTgt.useProxy">使用代理</a-checkbox>
</a-form-item>
</a-form>
</div>
</div>
<div style="text-align: center; margin-top: 16px;">
<a-button
type="primary"
:loading="onlineCompareLoading"
:disabled="!onlineMigrateSrc.platform || !onlineMigrateSrc.username || !onlineMigrateSrc.token || !onlineMigrateTgt.platform || !onlineMigrateTgt.username || !onlineMigrateTgt.token"
@click="doOnlineCompare"
>
<template #icon><SyncOutlined /></template>
开始对比
</a-button>
</div>
</div>
<!-- Step 2: 对比结果 -->
<div v-else-if="onlineMigrateStep === 'compare'">
<!-- 统计信息 -->
<div class="online-migrate-stats">
<a-tag color="blue">源平台: {{ onlineCompareResult.sourceRepos?.length || 0 }} 个仓库</a-tag>
<a-tag color="green">目标平台: {{ onlineCompareResult.targetRepos?.length || 0 }} 个仓库</a-tag>
<a-tag color="orange">需迁移: {{ onlineCompareResult.missingRepos?.length || 0 }} 个仓库</a-tag>
</div>
<!-- 缺少仓库列表 -->
<div v-if="onlineCompareResult.missingRepos?.length" class="batch-panel">
<div class="batch-header">
<a-checkbox
:checked="selectedOnlineRepos.length === onlineCompareResult.missingRepos.length"
:indeterminate="selectedOnlineRepos.length > 0 && selectedOnlineRepos.length < onlineCompareResult.missingRepos.length"
@change="toggleAllOnlineRepos"
>
全选 ({{ selectedOnlineRepos.length }}/{{ onlineCompareResult.missingRepos.length }})
</a-checkbox>
</div>
<div class="batch-list" style="max-height: 340px;">
<div
v-for="repo in onlineCompareResult.missingRepos"
:key="repo.name"
class="batch-item"
>
<a-checkbox
:checked="selectedOnlineRepos.includes(repo.name)"
@change="toggleOnlineRepo(repo.name)"
style="flex-shrink: 0;"
/>
<div class="batch-item-info">
<div class="batch-item-name">{{ repo.name }}</div>
<div class="batch-item-meta">
<span v-if="repo.description" style="font-size: 11px; color: var(--text-muted);">{{ repo.description }}</span>
<a-tag v-if="repo.private" size="small" color="red">私有</a-tag>
<a-tag v-if="repo.fork" size="small" color="purple">Fork</a-tag>
</div>
</div>
</div>
</div>
</div>
<div v-else style="padding: 32px; text-align: center; color: var(--text-muted);">
目标平台已包含所有仓库无需迁移
</div>
<!-- 迁移选项 -->
<div v-if="onlineCompareResult.missingRepos?.length" class="migrate-options-section">
<div style="font-size: 12px; color: var(--text-muted); margin-bottom: 8px;">迁移选项除代码外还同步以下内容</div>
<div class="migrate-options-grid">
<a-checkbox v-model:checked="migrateOpts.labels">🏷 标签</a-checkbox>
<a-checkbox v-model:checked="migrateOpts.milestones">🎯 里程碑</a-checkbox>
<a-checkbox v-model:checked="migrateOpts.issues">📋 工单</a-checkbox>
<a-checkbox v-model:checked="migrateOpts.releases">📦 发布</a-checkbox>
<a-checkbox v-model:checked="migrateOpts.pullRequests">🔀 合并请求</a-checkbox>
</div>
</div>
<div style="display: flex; justify-content: space-between; margin-top: 16px;">
<a-button @click="onlineMigrateStep = 'form'">
返回修改
</a-button>
<a-button
type="primary"
:disabled="!selectedOnlineRepos.length"
:loading="onlineMigrateLoading"
@click="doOnlineMigrateExecute"
>
<template #icon><ExportOutlined /></template>
开始迁移 ({{ selectedOnlineRepos.length }})
</a-button>
</div>
</div>
<!-- Step 3: 迁移结果 -->
<div v-else-if="onlineMigrateStep === 'execute'">
<!-- 进度条 -->
<div v-if="onlineMigrateLoading" class="migrate-progress-section">
<a-progress
:percent="Math.round(migrateProgress.percent)"
:status="migrateProgress.phase === 'error' ? 'exception' : 'active'"
:stroke-color="{ from: '#89b4fa', to: '#a6e3a1' }"
style="margin-bottom: 12px;"
/>
<div class="migrate-progress-info">
<div class="migrate-progress-repo">
<LoadingOutlined style="margin-right: 6px; color: var(--accent);" />
<span>{{ migrateProgress.repoName || '准备中...' }}</span>
<a-tag v-if="migrateProgress.current && migrateProgress.total" size="small" style="margin-left: 8px;">
{{ migrateProgress.current }}/{{ migrateProgress.total }}
</a-tag>
</div>
<div class="migrate-progress-phase">
<a-tag v-if="migrateProgress.phase === 'clone'" color="blue">克隆中</a-tag>
<a-tag v-else-if="migrateProgress.phase === 'create'" color="cyan">创建仓库</a-tag>
<a-tag v-else-if="migrateProgress.phase === 'push'" color="green">推送中</a-tag>
<a-tag v-else-if="migrateProgress.phase === 'labels'" color="orange">迁移标签</a-tag>
<a-tag v-else-if="migrateProgress.phase === 'milestones'" color="purple">迁移里程碑</a-tag>
<a-tag v-else-if="migrateProgress.phase === 'issues'" color="geekblue">迁移工单</a-tag>
<a-tag v-else-if="migrateProgress.phase === 'releases'" color="magenta">迁移发布</a-tag>
<a-tag v-else-if="migrateProgress.phase === 'pullRequests'" color="volcano">迁移合并请求</a-tag>
<a-tag v-else-if="migrateProgress.phase" color="default">{{ migrateProgress.phase }}</a-tag>
</div>
<div class="migrate-progress-message">{{ migrateProgress.message }}</div>
</div>
</div>
<div v-else>
<div class="online-migrate-stats" style="margin-bottom: 12px;">
<a-tag color="green">成功: {{ onlineMigrateResults.filter(r => r.success).length }}</a-tag>
<a-tag color="red">失败: {{ onlineMigrateResults.filter(r => !r.success).length }}</a-tag>
</div>
<div class="batch-list" style="max-height: 400px;">
<div
v-for="r in onlineMigrateResults"
:key="r.name"
class="batch-item"
>
<div class="batch-result-icon">
<CheckCircleOutlined v-if="r.success" style="color: #a6e3a1;" />
<CloseCircleOutlined v-else style="color: #f38ba8;" />
</div>
<div class="batch-item-info">
<div class="batch-item-name">{{ r.name }}</div>
<div class="batch-item-meta">
<span style="font-size: 11px; color: var(--text-muted);">{{ r.message }}</span>
<a v-if="r.cloneUrl" :href="r.cloneUrl" target="_blank" style="font-size: 11px; color: var(--accent);">{{ r.cloneUrl }}</a>
</div>
</div>
</div>
</div>
<div style="display: flex; justify-content: space-between; margin-top: 16px;">
<a-button @click="onlineMigrateStep = 'form'">
重新配置
</a-button>
<a-button type="primary" @click="showOnlineMigrateModal = false">
完成
</a-button>
</div>
</div>
</div>
</a-modal>
</aside> </aside>
</template> </template>
@@ -1044,4 +1659,85 @@ onMounted(() => {
color: var(--text-muted); color: var(--text-muted);
margin-top: 2px; margin-top: 2px;
} }
/* 在线迁移 */
.online-migrate-platforms {
display: flex;
gap: 12px;
align-items: flex-start;
}
.online-migrate-platform-card {
flex: 1;
min-width: 0;
background: var(--bg-hover, rgba(255,255,255,0.04));
border-radius: 8px;
padding: 12px;
border: 1px solid var(--border-color, #313244);
}
.platform-card-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
color: var(--text-primary);
}
.online-migrate-arrow {
display: flex;
align-items: center;
justify-content: center;
padding-top: 100px;
flex-shrink: 0;
}
.online-migrate-stats {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
/* 迁移选项 */
.migrate-options-section {
margin-top: 12px;
padding: 10px 12px;
background: var(--bg-hover, rgba(255,255,255,0.04));
border-radius: 6px;
border: 1px solid var(--border-color, #313244);
}
.migrate-options-grid {
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
}
/* 迁移进度 */
.migrate-progress-section {
padding: 16px 0;
}
.migrate-progress-info {
margin-top: 8px;
}
.migrate-progress-repo {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 6px;
}
.migrate-progress-phase {
margin-bottom: 6px;
}
.migrate-progress-message {
font-size: 12px;
color: var(--text-muted);
word-break: break-all;
}
</style> </style>

View File

@@ -75,6 +75,21 @@ export function AddRemote(path, name, url) {
return $Call.ByID(3697916441, path, name, url); return $Call.ByID(3697916441, path, name, url);
} }
/**
* BatchMigrateProjects 批量迁移项目到目标平台
* @param {string[]} projectPaths
* @param {string} targetCredPlatform
* @param {string} targetCredUsername
* @param {string} description
* @param {boolean} $private
* @returns {$CancellablePromise<$models.MigrateResult[]>}
*/
export function BatchMigrateProjects(projectPaths, targetCredPlatform, targetCredUsername, description, $private) {
return $Call.ByID(1641390103, projectPaths, targetCredPlatform, targetCredUsername, description, $private).then(/** @type {($result: any) => any} */(($result) => {
return $$createType1($result);
}));
}
/** /**
* BatchPull 批量拉取指定项目 * BatchPull 批量拉取指定项目
* @param {string[]} paths * @param {string[]} paths
@@ -83,7 +98,7 @@ export function AddRemote(path, name, url) {
*/ */
export function BatchPull(paths, remote) { export function BatchPull(paths, remote) {
return $Call.ByID(758996647, paths, remote).then(/** @type {($result: any) => any} */(($result) => { return $Call.ByID(758996647, paths, remote).then(/** @type {($result: any) => any} */(($result) => {
return $$createType1($result); return $$createType3($result);
})); }));
} }
@@ -95,7 +110,7 @@ export function BatchPull(paths, remote) {
*/ */
export function BatchPush(paths, remote) { export function BatchPush(paths, remote) {
return $Call.ByID(794082076, paths, remote).then(/** @type {($result: any) => any} */(($result) => { return $Call.ByID(794082076, paths, remote).then(/** @type {($result: any) => any} */(($result) => {
return $$createType1($result); return $$createType3($result);
})); }));
} }
@@ -105,7 +120,7 @@ export function BatchPush(paths, remote) {
*/ */
export function CheckGitInstalled() { export function CheckGitInstalled() {
return $Call.ByID(464620426).then(/** @type {($result: any) => any} */(($result) => { return $Call.ByID(464620426).then(/** @type {($result: any) => any} */(($result) => {
return $$createType2($result); return $$createType4($result);
})); }));
} }
@@ -221,7 +236,7 @@ export function FetchProject(path, remote) {
*/ */
export function GetAllProjectOverview() { export function GetAllProjectOverview() {
return $Call.ByID(2453130151).then(/** @type {($result: any) => any} */(($result) => { return $Call.ByID(2453130151).then(/** @type {($result: any) => any} */(($result) => {
return $$createType4($result); return $$createType6($result);
})); }));
} }
@@ -231,7 +246,7 @@ export function GetAllProjectOverview() {
*/ */
export function GetAppSettings() { export function GetAppSettings() {
return $Call.ByID(428589026).then(/** @type {($result: any) => any} */(($result) => { return $Call.ByID(428589026).then(/** @type {($result: any) => any} */(($result) => {
return $$createType6($result); return $$createType8($result);
})); }));
} }
@@ -242,7 +257,7 @@ export function GetAppSettings() {
*/ */
export function GetBranches(path) { export function GetBranches(path) {
return $Call.ByID(1686190192, path).then(/** @type {($result: any) => any} */(($result) => { return $Call.ByID(1686190192, path).then(/** @type {($result: any) => any} */(($result) => {
return $$createType8($result); return $$createType10($result);
})); }));
} }
@@ -275,7 +290,7 @@ export function GetCommitFileDiff(path, hash, filePath) {
*/ */
export function GetCommitFiles(path, hash) { export function GetCommitFiles(path, hash) {
return $Call.ByID(2420707876, path, hash).then(/** @type {($result: any) => any} */(($result) => { return $Call.ByID(2420707876, path, hash).then(/** @type {($result: any) => any} */(($result) => {
return $$createType10($result); return $$createType12($result);
})); }));
} }
@@ -287,7 +302,7 @@ export function GetCommitFiles(path, hash) {
*/ */
export function GetCommitLog(path, count) { export function GetCommitLog(path, count) {
return $Call.ByID(1281870789, path, count).then(/** @type {($result: any) => any} */(($result) => { return $Call.ByID(1281870789, path, count).then(/** @type {($result: any) => any} */(($result) => {
return $$createType12($result); return $$createType14($result);
})); }));
} }
@@ -308,7 +323,7 @@ export function GetConflictFileContent(projectPath, filePath) {
*/ */
export function GetConflictFiles(path) { export function GetConflictFiles(path) {
return $Call.ByID(2446806137, path).then(/** @type {($result: any) => any} */(($result) => { return $Call.ByID(2446806137, path).then(/** @type {($result: any) => any} */(($result) => {
return $$createType14($result); return $$createType16($result);
})); }));
} }
@@ -318,7 +333,7 @@ export function GetConflictFiles(path) {
*/ */
export function GetCredentials() { export function GetCredentials() {
return $Call.ByID(4214051290).then(/** @type {($result: any) => any} */(($result) => { return $Call.ByID(4214051290).then(/** @type {($result: any) => any} */(($result) => {
return $$createType16($result); return $$createType18($result);
})); }));
} }
@@ -358,7 +373,7 @@ export function GetFileDiffStaged(projectPath, filePath) {
*/ */
export function GetGitGlobalConfig() { export function GetGitGlobalConfig() {
return $Call.ByID(154811497).then(/** @type {($result: any) => any} */(($result) => { return $Call.ByID(154811497).then(/** @type {($result: any) => any} */(($result) => {
return $$createType18($result); return $$createType20($result);
})); }));
} }
@@ -368,7 +383,7 @@ export function GetGitGlobalConfig() {
*/ */
export function GetGroups() { export function GetGroups() {
return $Call.ByID(1600884836).then(/** @type {($result: any) => any} */(($result) => { return $Call.ByID(1600884836).then(/** @type {($result: any) => any} */(($result) => {
return $$createType20($result); return $$createType22($result);
})); }));
} }
@@ -379,7 +394,7 @@ export function GetGroups() {
*/ */
export function GetProjectChangedFiles(path) { export function GetProjectChangedFiles(path) {
return $Call.ByID(2302591462, path).then(/** @type {($result: any) => any} */(($result) => { return $Call.ByID(2302591462, path).then(/** @type {($result: any) => any} */(($result) => {
return $$createType22($result); return $$createType24($result);
})); }));
} }
@@ -399,7 +414,7 @@ export function GetProjectProxy(path) {
*/ */
export function GetProjectStatus(path) { export function GetProjectStatus(path) {
return $Call.ByID(3451089027, path).then(/** @type {($result: any) => any} */(($result) => { return $Call.ByID(3451089027, path).then(/** @type {($result: any) => any} */(($result) => {
return $$createType24($result); return $$createType26($result);
})); }));
} }
@@ -409,7 +424,7 @@ export function GetProjectStatus(path) {
*/ */
export function GetProjectTree() { export function GetProjectTree() {
return $Call.ByID(651189689).then(/** @type {($result: any) => any} */(($result) => { return $Call.ByID(651189689).then(/** @type {($result: any) => any} */(($result) => {
return $$createType26($result); return $$createType28($result);
})); }));
} }
@@ -421,7 +436,21 @@ export function GetProjectTree() {
*/ */
export function GetRemoteBranches(path, remote) { export function GetRemoteBranches(path, remote) {
return $Call.ByID(994796370, path, remote).then(/** @type {($result: any) => any} */(($result) => { return $Call.ByID(994796370, path, remote).then(/** @type {($result: any) => any} */(($result) => {
return $$createType8($result); return $$createType10($result);
}));
}
/**
* GetRemoteReleases 获取指定远程仓库的 Release 列表
* overrideToken: 前端传入的 Token优先于凭证中的 Token
* @param {string} projectPath
* @param {string} remoteName
* @param {string} overrideToken
* @returns {$CancellablePromise<$models.ReleaseInfo[]>}
*/
export function GetRemoteReleases(projectPath, remoteName, overrideToken) {
return $Call.ByID(280863080, projectPath, remoteName, overrideToken).then(/** @type {($result: any) => any} */(($result) => {
return $$createType30($result);
})); }));
} }
@@ -432,7 +461,7 @@ export function GetRemoteBranches(path, remote) {
*/ */
export function GetRemotes(path) { export function GetRemotes(path) {
return $Call.ByID(975520027, path).then(/** @type {($result: any) => any} */(($result) => { return $Call.ByID(975520027, path).then(/** @type {($result: any) => any} */(($result) => {
return $$createType28($result); return $$createType32($result);
})); }));
} }
@@ -443,7 +472,7 @@ export function GetRemotes(path) {
*/ */
export function GetStashList(path) { export function GetStashList(path) {
return $Call.ByID(1948257945, path).then(/** @type {($result: any) => any} */(($result) => { return $Call.ByID(1948257945, path).then(/** @type {($result: any) => any} */(($result) => {
return $$createType30($result); return $$createType34($result);
})); }));
} }
@@ -454,7 +483,7 @@ export function GetStashList(path) {
*/ */
export function GetTags(path) { export function GetTags(path) {
return $Call.ByID(3979169621, path).then(/** @type {($result: any) => any} */(($result) => { return $Call.ByID(3979169621, path).then(/** @type {($result: any) => any} */(($result) => {
return $$createType32($result); return $$createType36($result);
})); }));
} }
@@ -487,6 +516,28 @@ export function MergeBranch(path, branch) {
return $Call.ByID(278161076, path, branch); return $Call.ByID(278161076, path, branch);
} }
/**
* MigrateProject 将项目迁移到目标平台
* targetCredPlatform + targetCredUsername 用于定位目标凭证
* repoName: 目标仓库名(空则用项目名)
* remoteName: 新增的远程名(空则用平台名)
* private: 是否私有仓库
* description: 仓库描述
* @param {string} projectPath
* @param {string} targetCredPlatform
* @param {string} targetCredUsername
* @param {string} repoName
* @param {string} remoteName
* @param {string} description
* @param {boolean} $private
* @returns {$CancellablePromise<$models.MigrateResult | null>}
*/
export function MigrateProject(projectPath, targetCredPlatform, targetCredUsername, repoName, remoteName, description, $private) {
return $Call.ByID(618007206, projectPath, targetCredPlatform, targetCredUsername, repoName, remoteName, description, $private).then(/** @type {($result: any) => any} */(($result) => {
return $$createType37($result);
}));
}
/** /**
* MoveProjectToGroup 移动项目到指定分组 * MoveProjectToGroup 移动项目到指定分组
* @param {string} path * @param {string} path
@@ -497,6 +548,46 @@ export function MoveProjectToGroup(path, group) {
return $Call.ByID(1699187666, path, group); return $Call.ByID(1699187666, path, group);
} }
/**
* OnlineMigrateCompare 对比两个平台的仓库列表
* @param {string} srcPlatform
* @param {string} srcBaseURL
* @param {string} srcUsername
* @param {string} srcToken
* @param {string} tgtPlatform
* @param {string} tgtBaseURL
* @param {string} tgtUsername
* @param {string} tgtToken
* @returns {$CancellablePromise<$models.OnlineMigrateCompareResult | null>}
*/
export function OnlineMigrateCompare(srcPlatform, srcBaseURL, srcUsername, srcToken, tgtPlatform, tgtBaseURL, tgtUsername, tgtToken) {
return $Call.ByID(3514758503, srcPlatform, srcBaseURL, srcUsername, srcToken, tgtPlatform, tgtBaseURL, tgtUsername, tgtToken).then(/** @type {($result: any) => any} */(($result) => {
return $$createType39($result);
}));
}
/**
* OnlineMigrateExecute 执行在线迁移
* @param {string} srcPlatform
* @param {string} srcBaseURL
* @param {string} srcUsername
* @param {string} srcToken
* @param {string} tgtPlatform
* @param {string} tgtBaseURL
* @param {string} tgtUsername
* @param {string} tgtToken
* @param {string[]} repoNames
* @param {boolean} srcUseProxy
* @param {boolean} tgtUseProxy
* @param {$models.MigrateOptions} opts
* @returns {$CancellablePromise<$models.OnlineMigrateItemResult[]>}
*/
export function OnlineMigrateExecute(srcPlatform, srcBaseURL, srcUsername, srcToken, tgtPlatform, tgtBaseURL, tgtUsername, tgtToken, repoNames, srcUseProxy, tgtUseProxy, opts) {
return $Call.ByID(357541815, srcPlatform, srcBaseURL, srcUsername, srcToken, tgtPlatform, tgtBaseURL, tgtUsername, tgtToken, repoNames, srcUseProxy, tgtUseProxy, opts).then(/** @type {($result: any) => any} */(($result) => {
return $$createType41($result);
}));
}
/** /**
* PullProject 拉取项目(当前分支,指定 remote * PullProject 拉取项目(当前分支,指定 remote
* @param {string} path * @param {string} path
@@ -536,7 +627,7 @@ export function PushTag(path, name, remote) {
*/ */
export function PushTagToAllRemotes(path, tagName) { export function PushTagToAllRemotes(path, tagName) {
return $Call.ByID(2343581171, path, tagName).then(/** @type {($result: any) => any} */(($result) => { return $Call.ByID(2343581171, path, tagName).then(/** @type {($result: any) => any} */(($result) => {
return $$createType34($result); return $$createType43($result);
})); }));
} }
@@ -547,7 +638,7 @@ export function PushTagToAllRemotes(path, tagName) {
*/ */
export function PushToAllRemotes(path) { export function PushToAllRemotes(path) {
return $Call.ByID(1027392731, path).then(/** @type {($result: any) => any} */(($result) => { return $Call.ByID(1027392731, path).then(/** @type {($result: any) => any} */(($result) => {
return $$createType34($result); return $$createType43($result);
})); }));
} }
@@ -642,7 +733,7 @@ export function SaveConflictFile(projectPath, filePath, content) {
*/ */
export function SearchCommitLog(path, keyword, author, maxCount) { export function SearchCommitLog(path, keyword, author, maxCount) {
return $Call.ByID(2874450917, path, keyword, author, maxCount).then(/** @type {($result: any) => any} */(($result) => { return $Call.ByID(2874450917, path, keyword, author, maxCount).then(/** @type {($result: any) => any} */(($result) => {
return $$createType12($result); return $$createType14($result);
})); }));
} }
@@ -760,6 +851,22 @@ export function SwitchBranch(path, branch) {
return $Call.ByID(4262224900, path, branch); return $Call.ByID(4262224900, path, branch);
} }
/**
* SyncReleases 将源远程仓库的 Release 同步到目标远程仓库
* 仅同步目标不存在的 Release包括附件
* @param {string} projectPath
* @param {string} sourceRemote
* @param {string} targetRemote
* @param {string} srcOverrideToken
* @param {string} tgtOverrideToken
* @returns {$CancellablePromise<$models.ReleaseSyncResult[]>}
*/
export function SyncReleases(projectPath, sourceRemote, targetRemote, srcOverrideToken, tgtOverrideToken) {
return $Call.ByID(3895607505, projectPath, sourceRemote, targetRemote, srcOverrideToken, tgtOverrideToken).then(/** @type {($result: any) => any} */(($result) => {
return $$createType45($result);
}));
}
/** /**
* UnstageAll 取消暂存所有文件 * UnstageAll 取消暂存所有文件
* @param {string} path * @param {string} path
@@ -812,38 +919,49 @@ export function UpdateGroup(oldName, newName, icon) {
} }
// Private type creation functions // Private type creation functions
const $$createType0 = $models.BatchPullResult.createFrom; const $$createType0 = $models.MigrateResult.createFrom;
const $$createType1 = $Create.Array($$createType0); const $$createType1 = $Create.Array($$createType0);
const $$createType2 = $models.GitStatus.createFrom; const $$createType2 = $models.BatchPullResult.createFrom;
const $$createType3 = $models.ProjectOverview.createFrom; const $$createType3 = $Create.Array($$createType2);
const $$createType4 = $Create.Array($$createType3); const $$createType4 = $models.GitStatus.createFrom;
const $$createType5 = config$0.Settings.createFrom; const $$createType5 = $models.ProjectOverview.createFrom;
const $$createType6 = $Create.Nullable($$createType5); const $$createType6 = $Create.Array($$createType5);
const $$createType7 = $models.BranchInfo.createFrom; const $$createType7 = config$0.Settings.createFrom;
const $$createType8 = $Create.Array($$createType7); const $$createType8 = $Create.Nullable($$createType7);
const $$createType9 = $models.CommitFileInfo.createFrom; const $$createType9 = $models.BranchInfo.createFrom;
const $$createType10 = $Create.Array($$createType9); const $$createType10 = $Create.Array($$createType9);
const $$createType11 = $models.CommitLog.createFrom; const $$createType11 = $models.CommitFileInfo.createFrom;
const $$createType12 = $Create.Array($$createType11); const $$createType12 = $Create.Array($$createType11);
const $$createType13 = $models.ConflictFileInfo.createFrom; const $$createType13 = $models.CommitLog.createFrom;
const $$createType14 = $Create.Array($$createType13); const $$createType14 = $Create.Array($$createType13);
const $$createType15 = $models.CredentialInfo.createFrom; const $$createType15 = $models.ConflictFileInfo.createFrom;
const $$createType16 = $Create.Array($$createType15); const $$createType16 = $Create.Array($$createType15);
const $$createType17 = $models.GitConfig.createFrom; const $$createType17 = $models.CredentialInfo.createFrom;
const $$createType18 = $Create.Nullable($$createType17); const $$createType18 = $Create.Array($$createType17);
const $$createType19 = config$0.Group.createFrom; const $$createType19 = $models.GitConfig.createFrom;
const $$createType20 = $Create.Array($$createType19); const $$createType20 = $Create.Nullable($$createType19);
const $$createType21 = $models.FileInfo.createFrom; const $$createType21 = config$0.Group.createFrom;
const $$createType22 = $Create.Array($$createType21); const $$createType22 = $Create.Array($$createType21);
const $$createType23 = $models.ProjectStatus.createFrom; const $$createType23 = $models.FileInfo.createFrom;
const $$createType24 = $Create.Nullable($$createType23); const $$createType24 = $Create.Array($$createType23);
const $$createType25 = $models.TreeNode.createFrom; const $$createType25 = $models.ProjectStatus.createFrom;
const $$createType26 = $Create.Array($$createType25); const $$createType26 = $Create.Nullable($$createType25);
const $$createType27 = $models.RemoteItem.createFrom; const $$createType27 = $models.TreeNode.createFrom;
const $$createType28 = $Create.Array($$createType27); const $$createType28 = $Create.Array($$createType27);
const $$createType29 = $models.StashInfo.createFrom; const $$createType29 = $models.ReleaseInfo.createFrom;
const $$createType30 = $Create.Array($$createType29); const $$createType30 = $Create.Array($$createType29);
const $$createType31 = $models.TagInfo.createFrom; const $$createType31 = $models.RemoteItem.createFrom;
const $$createType32 = $Create.Array($$createType31); const $$createType32 = $Create.Array($$createType31);
const $$createType33 = $models.PushAllResult.createFrom; const $$createType33 = $models.StashInfo.createFrom;
const $$createType34 = $Create.Array($$createType33); const $$createType34 = $Create.Array($$createType33);
const $$createType35 = $models.TagInfo.createFrom;
const $$createType36 = $Create.Array($$createType35);
const $$createType37 = $Create.Nullable($$createType0);
const $$createType38 = $models.OnlineMigrateCompareResult.createFrom;
const $$createType39 = $Create.Nullable($$createType38);
const $$createType40 = $models.OnlineMigrateItemResult.createFrom;
const $$createType41 = $Create.Array($$createType40);
const $$createType42 = $models.PushAllResult.createFrom;
const $$createType43 = $Create.Array($$createType42);
const $$createType44 = $models.ReleaseSyncResult.createFrom;
const $$createType45 = $Create.Array($$createType44);

View File

@@ -8,6 +8,7 @@ export {
}; };
export { export {
AssetInfo,
BatchPullResult, BatchPullResult,
BranchInfo, BranchInfo,
CommitFileInfo, CommitFileInfo,
@@ -17,10 +18,17 @@ export {
FileInfo, FileInfo,
GitConfig, GitConfig,
GitStatus, GitStatus,
MigrateOptions,
MigrateResult,
OnlineMigrateCompareResult,
OnlineMigrateItemResult,
ProjectOverview, ProjectOverview,
ProjectStatus, ProjectStatus,
PushAllResult, PushAllResult,
ReleaseInfo,
ReleaseSyncResult,
RemoteItem, RemoteItem,
RepoInfo,
StashInfo, StashInfo,
TagInfo, TagInfo,
TreeNode TreeNode

View File

@@ -6,6 +6,58 @@
// @ts-ignore: Unused imports // @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime"; import { Create as $Create } from "@wailsio/runtime";
/**
* AssetInfo Release 附件
*/
export class AssetInfo {
/**
* Creates a new AssetInfo instance.
* @param {Partial<AssetInfo>} [$$source = {}] - The source object to create the AssetInfo.
*/
constructor($$source = {}) {
if (!("id" in $$source)) {
/**
* @member
* @type {number}
*/
this["id"] = 0;
}
if (!("name" in $$source)) {
/**
* @member
* @type {string}
*/
this["name"] = "";
}
if (!("size" in $$source)) {
/**
* @member
* @type {number}
*/
this["size"] = 0;
}
if (!("downloadUrl" in $$source)) {
/**
* @member
* @type {string}
*/
this["downloadUrl"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new AssetInfo instance from a string or object.
* @param {any} [$$source = {}]
* @returns {AssetInfo}
*/
static createFrom($$source = {}) {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new AssetInfo(/** @type {Partial<AssetInfo>} */($$parsedSource));
}
}
/** /**
* BatchPullResult 批量 pull 结果 * BatchPullResult 批量 pull 结果
*/ */
@@ -425,6 +477,243 @@ export class GitStatus {
} }
} }
/**
* MigrateOptions 迁移选项
*/
export class MigrateOptions {
/**
* Creates a new MigrateOptions instance.
* @param {Partial<MigrateOptions>} [$$source = {}] - The source object to create the MigrateOptions.
*/
constructor($$source = {}) {
if (!("labels" in $$source)) {
/**
* @member
* @type {boolean}
*/
this["labels"] = false;
}
if (!("issues" in $$source)) {
/**
* @member
* @type {boolean}
*/
this["issues"] = false;
}
if (!("pullRequests" in $$source)) {
/**
* @member
* @type {boolean}
*/
this["pullRequests"] = false;
}
if (!("releases" in $$source)) {
/**
* @member
* @type {boolean}
*/
this["releases"] = false;
}
if (!("milestones" in $$source)) {
/**
* @member
* @type {boolean}
*/
this["milestones"] = false;
}
Object.assign(this, $$source);
}
/**
* Creates a new MigrateOptions instance from a string or object.
* @param {any} [$$source = {}]
* @returns {MigrateOptions}
*/
static createFrom($$source = {}) {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new MigrateOptions(/** @type {Partial<MigrateOptions>} */($$parsedSource));
}
}
/**
* MigrateResult 迁移结果
*/
export class MigrateResult {
/**
* Creates a new MigrateResult instance.
* @param {Partial<MigrateResult>} [$$source = {}] - The source object to create the MigrateResult.
*/
constructor($$source = {}) {
if (!("path" in $$source)) {
/**
* @member
* @type {string}
*/
this["path"] = "";
}
if (!("name" in $$source)) {
/**
* @member
* @type {string}
*/
this["name"] = "";
}
if (!("success" in $$source)) {
/**
* @member
* @type {boolean}
*/
this["success"] = false;
}
if (!("message" in $$source)) {
/**
* @member
* @type {string}
*/
this["message"] = "";
}
if (/** @type {any} */(false)) {
/**
* @member
* @type {string | undefined}
*/
this["cloneUrl"] = undefined;
}
if (/** @type {any} */(false)) {
/**
* @member
* @type {string | undefined}
*/
this["remoteName"] = undefined;
}
Object.assign(this, $$source);
}
/**
* Creates a new MigrateResult instance from a string or object.
* @param {any} [$$source = {}]
* @returns {MigrateResult}
*/
static createFrom($$source = {}) {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new MigrateResult(/** @type {Partial<MigrateResult>} */($$parsedSource));
}
}
/**
* OnlineMigrateCompareResult 在线迁移对比结果
*/
export class OnlineMigrateCompareResult {
/**
* Creates a new OnlineMigrateCompareResult instance.
* @param {Partial<OnlineMigrateCompareResult>} [$$source = {}] - The source object to create the OnlineMigrateCompareResult.
*/
constructor($$source = {}) {
if (!("sourceRepos" in $$source)) {
/**
* 源平台所有仓库
* @member
* @type {RepoInfo[]}
*/
this["sourceRepos"] = [];
}
if (!("targetRepos" in $$source)) {
/**
* 目标平台所有仓库
* @member
* @type {RepoInfo[]}
*/
this["targetRepos"] = [];
}
if (!("missingRepos" in $$source)) {
/**
* 目标平台缺少的仓库
* @member
* @type {RepoInfo[]}
*/
this["missingRepos"] = [];
}
Object.assign(this, $$source);
}
/**
* Creates a new OnlineMigrateCompareResult instance from a string or object.
* @param {any} [$$source = {}]
* @returns {OnlineMigrateCompareResult}
*/
static createFrom($$source = {}) {
const $$createField0_0 = $$createType1;
const $$createField1_0 = $$createType1;
const $$createField2_0 = $$createType1;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("sourceRepos" in $$parsedSource) {
$$parsedSource["sourceRepos"] = $$createField0_0($$parsedSource["sourceRepos"]);
}
if ("targetRepos" in $$parsedSource) {
$$parsedSource["targetRepos"] = $$createField1_0($$parsedSource["targetRepos"]);
}
if ("missingRepos" in $$parsedSource) {
$$parsedSource["missingRepos"] = $$createField2_0($$parsedSource["missingRepos"]);
}
return new OnlineMigrateCompareResult(/** @type {Partial<OnlineMigrateCompareResult>} */($$parsedSource));
}
}
/**
* OnlineMigrateItemResult 单个仓库在线迁移结果
*/
export class OnlineMigrateItemResult {
/**
* Creates a new OnlineMigrateItemResult instance.
* @param {Partial<OnlineMigrateItemResult>} [$$source = {}] - The source object to create the OnlineMigrateItemResult.
*/
constructor($$source = {}) {
if (!("name" in $$source)) {
/**
* @member
* @type {string}
*/
this["name"] = "";
}
if (/** @type {any} */(false)) {
/**
* @member
* @type {string | undefined}
*/
this["cloneUrl"] = undefined;
}
if (!("success" in $$source)) {
/**
* @member
* @type {boolean}
*/
this["success"] = false;
}
if (!("message" in $$source)) {
/**
* @member
* @type {string}
*/
this["message"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new OnlineMigrateItemResult instance from a string or object.
* @param {any} [$$source = {}]
* @returns {OnlineMigrateItemResult}
*/
static createFrom($$source = {}) {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new OnlineMigrateItemResult(/** @type {Partial<OnlineMigrateItemResult>} */($$parsedSource));
}
}
/** /**
* ProjectOverview 项目概览信息(轻量级) * ProjectOverview 项目概览信息(轻量级)
*/ */
@@ -560,8 +849,8 @@ export class ProjectStatus {
* @returns {ProjectStatus} * @returns {ProjectStatus}
*/ */
static createFrom($$source = {}) { static createFrom($$source = {}) {
const $$createField2_0 = $$createType1; const $$createField2_0 = $$createType3;
const $$createField4_0 = $$createType3; const $$createField4_0 = $$createType5;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("remotes" in $$parsedSource) { if ("remotes" in $$parsedSource) {
$$parsedSource["remotes"] = $$createField2_0($$parsedSource["remotes"]); $$parsedSource["remotes"] = $$createField2_0($$parsedSource["remotes"]);
@@ -618,6 +907,149 @@ export class PushAllResult {
} }
} }
/**
* ReleaseInfo 通用 Release 信息
*/
export class ReleaseInfo {
/**
* Creates a new ReleaseInfo instance.
* @param {Partial<ReleaseInfo>} [$$source = {}] - The source object to create the ReleaseInfo.
*/
constructor($$source = {}) {
if (!("id" in $$source)) {
/**
* @member
* @type {number}
*/
this["id"] = 0;
}
if (!("tagName" in $$source)) {
/**
* @member
* @type {string}
*/
this["tagName"] = "";
}
if (!("name" in $$source)) {
/**
* @member
* @type {string}
*/
this["name"] = "";
}
if (!("body" in $$source)) {
/**
* @member
* @type {string}
*/
this["body"] = "";
}
if (!("draft" in $$source)) {
/**
* @member
* @type {boolean}
*/
this["draft"] = false;
}
if (!("prerelease" in $$source)) {
/**
* @member
* @type {boolean}
*/
this["prerelease"] = false;
}
if (!("createdAt" in $$source)) {
/**
* @member
* @type {string}
*/
this["createdAt"] = "";
}
if (!("publishedAt" in $$source)) {
/**
* @member
* @type {string}
*/
this["publishedAt"] = "";
}
if (!("assets" in $$source)) {
/**
* @member
* @type {AssetInfo[]}
*/
this["assets"] = [];
}
Object.assign(this, $$source);
}
/**
* Creates a new ReleaseInfo instance from a string or object.
* @param {any} [$$source = {}]
* @returns {ReleaseInfo}
*/
static createFrom($$source = {}) {
const $$createField8_0 = $$createType7;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("assets" in $$parsedSource) {
$$parsedSource["assets"] = $$createField8_0($$parsedSource["assets"]);
}
return new ReleaseInfo(/** @type {Partial<ReleaseInfo>} */($$parsedSource));
}
}
/**
* ReleaseSyncResult 单条 Release 同步结果
*/
export class ReleaseSyncResult {
/**
* Creates a new ReleaseSyncResult instance.
* @param {Partial<ReleaseSyncResult>} [$$source = {}] - The source object to create the ReleaseSyncResult.
*/
constructor($$source = {}) {
if (!("tagName" in $$source)) {
/**
* @member
* @type {string}
*/
this["tagName"] = "";
}
if (!("name" in $$source)) {
/**
* @member
* @type {string}
*/
this["name"] = "";
}
if (!("success" in $$source)) {
/**
* @member
* @type {boolean}
*/
this["success"] = false;
}
if (!("message" in $$source)) {
/**
* @member
* @type {string}
*/
this["message"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new ReleaseSyncResult instance from a string or object.
* @param {any} [$$source = {}]
* @returns {ReleaseSyncResult}
*/
static createFrom($$source = {}) {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new ReleaseSyncResult(/** @type {Partial<ReleaseSyncResult>} */($$parsedSource));
}
}
/** /**
* RemoteItem 远程仓库信息 * RemoteItem 远程仓库信息
*/ */
@@ -656,6 +1088,93 @@ export class RemoteItem {
} }
} }
/**
* RepoInfo 仓库信息
*/
export class RepoInfo {
/**
* Creates a new RepoInfo instance.
* @param {Partial<RepoInfo>} [$$source = {}] - The source object to create the RepoInfo.
*/
constructor($$source = {}) {
if (!("name" in $$source)) {
/**
* @member
* @type {string}
*/
this["name"] = "";
}
if (!("fullName" in $$source)) {
/**
* @member
* @type {string}
*/
this["fullName"] = "";
}
if (!("description" in $$source)) {
/**
* @member
* @type {string}
*/
this["description"] = "";
}
if (!("cloneUrl" in $$source)) {
/**
* @member
* @type {string}
*/
this["cloneUrl"] = "";
}
if (!("sshUrl" in $$source)) {
/**
* @member
* @type {string}
*/
this["sshUrl"] = "";
}
if (!("private" in $$source)) {
/**
* @member
* @type {boolean}
*/
this["private"] = false;
}
if (!("fork" in $$source)) {
/**
* @member
* @type {boolean}
*/
this["fork"] = false;
}
if (!("empty" in $$source)) {
/**
* @member
* @type {boolean}
*/
this["empty"] = false;
}
if (!("updatedAt" in $$source)) {
/**
* @member
* @type {string}
*/
this["updatedAt"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new RepoInfo instance from a string or object.
* @param {any} [$$source = {}]
* @returns {RepoInfo}
*/
static createFrom($$source = {}) {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new RepoInfo(/** @type {Partial<RepoInfo>} */($$parsedSource));
}
}
/** /**
* StashInfo 贮藏信息 * StashInfo 贮藏信息
*/ */
@@ -822,7 +1341,7 @@ export class TreeNode {
* @returns {TreeNode} * @returns {TreeNode}
*/ */
static createFrom($$source = {}) { static createFrom($$source = {}) {
const $$createField5_0 = $$createType5; const $$createField5_0 = $$createType9;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("children" in $$parsedSource) { if ("children" in $$parsedSource) {
$$parsedSource["children"] = $$createField5_0($$parsedSource["children"]); $$parsedSource["children"] = $$createField5_0($$parsedSource["children"]);
@@ -832,9 +1351,13 @@ export class TreeNode {
} }
// Private type creation functions // Private type creation functions
const $$createType0 = RemoteItem.createFrom; const $$createType0 = RepoInfo.createFrom;
const $$createType1 = $Create.Array($$createType0); const $$createType1 = $Create.Array($$createType0);
const $$createType2 = FileInfo.createFrom; const $$createType2 = RemoteItem.createFrom;
const $$createType3 = $Create.Array($$createType2); const $$createType3 = $Create.Array($$createType2);
const $$createType4 = TreeNode.createFrom; const $$createType4 = FileInfo.createFrom;
const $$createType5 = $Create.Array($$createType4); const $$createType5 = $Create.Array($$createType4);
const $$createType6 = AssetInfo.createFrom;
const $$createType7 = $Create.Array($$createType6);
const $$createType8 = TreeNode.createFrom;
const $$createType9 = $Create.Array($$createType8);

420
internal/app/migration.go Normal file
View 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
}

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)
}

1126
internal/app/platform.go Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -62,6 +62,35 @@ func (g *GitClient) RunWithProxy(path string, useProxy *bool, args ...string) (s
return stdout.String(), nil return stdout.String(), nil
} }
// RunWithProxyTimeout 执行 git 命令,支持自定义超时
func (g *GitClient) RunWithProxyTimeout(path string, useProxy *bool, timeout time.Duration, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
header := []string{"-C", path, "-c", "core.quotePath=false"}
enableProxy := g.Enabled
if useProxy != nil {
enableProxy = *useProxy
}
if enableProxy {
header = append(header, "-c", "http.proxy="+g.Proxy, "-c", "https.proxy="+g.Proxy)
}
argsArr := append(header, args...)
log.Println(argsArr)
cmd := exec.CommandContext(ctx, "git", argsArr...)
hideWindow(cmd)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("git command timeout: git %v", args)
}
if err != nil {
return "", fmt.Errorf("git command error: %v, stderr: %s", err, stderr.String())
}
return stdout.String(), nil
}
func (g *GitClient) Pull(path string) (string, error) { func (g *GitClient) Pull(path string) (string, error) {
return g.Run(path, "pull") return g.Run(path, "pull")
} }
@@ -90,11 +119,20 @@ func (g *GitClient) Status(path string) (string, error) {
} }
func (g *GitClient) Clone(repoURL, path string) (string, error) { func (g *GitClient) Clone(repoURL, path string) (string, error) {
return g.CloneWithProxy(repoURL, path, nil)
}
// CloneWithProxy 克隆仓库useProxy 可独立控制代理
func (g *GitClient) CloneWithProxy(repoURL, path string, useProxy *bool) (string, error) {
// Clone 操作可能需要较长时间使用独立的超时设置10 分钟) // Clone 操作可能需要较长时间使用独立的超时设置10 分钟)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel() defer cancel()
args := []string{"-c", "core.quotePath=false"} args := []string{"-c", "core.quotePath=false"}
if g.Enabled { enableProxy := g.Enabled
if useProxy != nil {
enableProxy = *useProxy
}
if enableProxy {
args = append(args, "-c", "http.proxy="+g.Proxy, "-c", "https.proxy="+g.Proxy) args = append(args, "-c", "http.proxy="+g.Proxy, "-c", "https.proxy="+g.Proxy)
} }
args = append(args, "clone", "--progress", repoURL, path) args = append(args, "clone", "--progress", repoURL, path)