diff --git a/CHANGELOG.md b/CHANGELOG.md
index db03286..e3864d3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,28 @@
# 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)
### 🔧 架构重构
diff --git a/build/config.yml b/build/config.yml
index 822f227..7b9d265 100644
--- a/build/config.yml
+++ b/build/config.yml
@@ -9,7 +9,7 @@ info:
description: "Git Repository Management Tool"
copyright: "(c) 2025, GitPilot"
comments: "GitPilot - Git Repository Manager"
- version: "0.4.0"
+ version: "0.4.1"
dev_mode:
root_path: .
diff --git a/build/darwin/Info.plist b/build/darwin/Info.plist
index a3d1904..4da9f2c 100644
--- a/build/darwin/Info.plist
+++ b/build/darwin/Info.plist
@@ -10,11 +10,11 @@
CFBundleIdentifier
com.gitpilot.app
CFBundleVersion
- 0.4.0
+ 0.4.1
CFBundleGetInfoString
Git Repository Management Tool
CFBundleShortVersionString
- 0.4.0
+ 0.4.1
CFBundleIconFile
icons
LSMinimumSystemVersion
diff --git a/build/windows/Taskfile.yml b/build/windows/Taskfile.yml
index bd75ea0..edb3375 100644
--- a/build/windows/Taskfile.yml
+++ b/build/windows/Taskfile.yml
@@ -18,9 +18,9 @@ tasks:
cmds:
- task: generate:syso
- 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]
- - 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]
- cmd: powershell Remove-item *.syso
platforms: [windows]
diff --git a/build/windows/info.json b/build/windows/info.json
index 3f2106e..eac7abe 100644
--- a/build/windows/info.json
+++ b/build/windows/info.json
@@ -1,10 +1,10 @@
{
"fixed": {
- "file_version": "0.4.0"
+ "file_version": "0.4.1"
},
"info": {
"0000": {
- "ProductVersion": "0.4.0",
+ "ProductVersion": "0.4.1",
"CompanyName": "GitPilot",
"FileDescription": "Git Repository Management Tool",
"LegalCopyright": "© 2025, GitPilot",
diff --git a/config.yaml b/config.yaml
index b077af8..67b1882 100644
--- a/config.yaml
+++ b/config.yaml
@@ -15,8 +15,8 @@ credentials:
- platform: gitee
username: zhuyuj
- platform: gitea
- base_url: http://192.168.1.10:3000
- username: ""
+ base_url: http://localhost:3000
+ username: "zhuyujun"
settings:
concurrency: 6
network_check: true
diff --git a/frontend/app/components/ContentArea.vue b/frontend/app/components/ContentArea.vue
index eaf6441..e99f34a 100644
--- a/frontend/app/components/ContentArea.vue
+++ b/frontend/app/components/ContentArea.vue
@@ -85,6 +85,17 @@ const newTagName = ref('')
const newTagMessage = ref('')
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 createBranchLoading = ref(false)
@@ -1256,8 +1267,57 @@ watch(activeTab, (tab) => {
if (tab === 'tags' && !tags.value.length && !tagsLoading.value) {
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 远程仓库管理 ----
async function switchRemote(remoteName) {
currentRemote.value = remoteName
@@ -1549,6 +1609,9 @@ const currentRemoteUrl = computed(() => {
标签
+
+ 发布
+
@@ -1962,6 +2025,94 @@ const currentRemoteUrl = computed(() => {
+
+
+
+
+
+
+
+
源
+
+ {{ r.name }}
+
+
+
+
+
+
+
目标
+
+ {{ r.name }}
+
+
+
+ 同步
+
+
+
+
+
+
+
+
+
+
+
+
+
+
选择源 remote 以查看 Release
+
+
+
+
+
+
{{ rel.name || rel.tagName }}
+
草稿
+
预发布
+
+
+
+ {{ rel.tagName }}
+ {{ formatTime(rel.publishedAt || rel.createdAt) }}
+
+
+
+
+
+
+
+
同步结果
+
+
+
+ {{ r.tagName }}
+ {{ r.message }}
+
+
+
+
@@ -2050,6 +2201,42 @@ const currentRemoteUrl = computed(() => {
共 {{ tags.length }} 个标签
+
+
+
+
+
+
{{ selectedRelease.body }}
+
+
+
附件 ({{ selectedRelease.assets.length }})
+
+
+ {{ asset.name }}
+ {{ formatFileSize(asset.size) }}
+
+
+
+
+
+ 选择一个 Release 查看详情
+ 共 {{ releases.length }} 个 Release
+
+
@@ -3059,4 +3246,43 @@ const currentRemoteUrl = computed(() => {
border-bottom: 1px solid var(--border-color);
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);
+}
diff --git a/frontend/app/components/Sidebar.vue b/frontend/app/components/Sidebar.vue
index d372083..d05562e 100644
--- a/frontend/app/components/Sidebar.vue
+++ b/frontend/app/components/Sidebar.vue
@@ -1,5 +1,5 @@
@@ -495,6 +730,12 @@ onMounted(() => {
+
+
+
+
+
+
+
目标平台凭证
+
+
+ {{ c.platform }} / {{ c.username }}
+ ({{ c.baseUrl }})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
源平台: {{ onlineCompareResult.sourceRepos?.length || 0 }} 个仓库
+
目标平台: {{ onlineCompareResult.targetRepos?.length || 0 }} 个仓库
+
需迁移: {{ onlineCompareResult.missingRepos?.length || 0 }} 个仓库
+
+
+
+
+
+ ✅ 目标平台已包含所有仓库,无需迁移
+
+
+
+
+
迁移选项(除代码外,还同步以下内容)
+
+
🏷️ 标签
+
🎯 里程碑
+
📋 工单
+
📦 发布
+
🔀 合并请求
+
+
+
+
+
+ 返回修改
+
+
+
+ 开始迁移 ({{ selectedOnlineRepos.length }})
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ migrateProgress.repoName || '准备中...' }}
+
+ {{ migrateProgress.current }}/{{ migrateProgress.total }}
+
+
+
+
克隆中
+
创建仓库
+
推送中
+
迁移标签
+
迁移里程碑
+
迁移工单
+
迁移发布
+
迁移合并请求
+
{{ migrateProgress.phase }}
+
+
{{ migrateProgress.message }}
+
+
+
+
+
成功: {{ onlineMigrateResults.filter(r => r.success).length }}
+
失败: {{ onlineMigrateResults.filter(r => !r.success).length }}
+
+
+
+
+
+
@@ -1044,4 +1659,85 @@ onMounted(() => {
color: var(--text-muted);
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;
+}
diff --git a/frontend/bindings/github.com/zhuy1228/GitPilot/internal/app/appservice.js b/frontend/bindings/github.com/zhuy1228/GitPilot/internal/app/appservice.js
index 45476dc..9fd10a0 100644
--- a/frontend/bindings/github.com/zhuy1228/GitPilot/internal/app/appservice.js
+++ b/frontend/bindings/github.com/zhuy1228/GitPilot/internal/app/appservice.js
@@ -75,6 +75,21 @@ export function AddRemote(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 批量拉取指定项目
* @param {string[]} paths
@@ -83,7 +98,7 @@ export function AddRemote(path, name, url) {
*/
export function BatchPull(paths, remote) {
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) {
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() {
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() {
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() {
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) {
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) {
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) {
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) {
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() {
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() {
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() {
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) {
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) {
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() {
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) {
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) {
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) {
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) {
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);
}
+/**
+ * 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 移动项目到指定分组
* @param {string} path
@@ -497,6 +548,46 @@ export function MoveProjectToGroup(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)
* @param {string} path
@@ -536,7 +627,7 @@ export function PushTag(path, name, remote) {
*/
export function PushTagToAllRemotes(path, tagName) {
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) {
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) {
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);
}
+/**
+ * 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 取消暂存所有文件
* @param {string} path
@@ -812,38 +919,49 @@ export function UpdateGroup(oldName, newName, icon) {
}
// Private type creation functions
-const $$createType0 = $models.BatchPullResult.createFrom;
+const $$createType0 = $models.MigrateResult.createFrom;
const $$createType1 = $Create.Array($$createType0);
-const $$createType2 = $models.GitStatus.createFrom;
-const $$createType3 = $models.ProjectOverview.createFrom;
-const $$createType4 = $Create.Array($$createType3);
-const $$createType5 = config$0.Settings.createFrom;
-const $$createType6 = $Create.Nullable($$createType5);
-const $$createType7 = $models.BranchInfo.createFrom;
-const $$createType8 = $Create.Array($$createType7);
-const $$createType9 = $models.CommitFileInfo.createFrom;
+const $$createType2 = $models.BatchPullResult.createFrom;
+const $$createType3 = $Create.Array($$createType2);
+const $$createType4 = $models.GitStatus.createFrom;
+const $$createType5 = $models.ProjectOverview.createFrom;
+const $$createType6 = $Create.Array($$createType5);
+const $$createType7 = config$0.Settings.createFrom;
+const $$createType8 = $Create.Nullable($$createType7);
+const $$createType9 = $models.BranchInfo.createFrom;
const $$createType10 = $Create.Array($$createType9);
-const $$createType11 = $models.CommitLog.createFrom;
+const $$createType11 = $models.CommitFileInfo.createFrom;
const $$createType12 = $Create.Array($$createType11);
-const $$createType13 = $models.ConflictFileInfo.createFrom;
+const $$createType13 = $models.CommitLog.createFrom;
const $$createType14 = $Create.Array($$createType13);
-const $$createType15 = $models.CredentialInfo.createFrom;
+const $$createType15 = $models.ConflictFileInfo.createFrom;
const $$createType16 = $Create.Array($$createType15);
-const $$createType17 = $models.GitConfig.createFrom;
-const $$createType18 = $Create.Nullable($$createType17);
-const $$createType19 = config$0.Group.createFrom;
-const $$createType20 = $Create.Array($$createType19);
-const $$createType21 = $models.FileInfo.createFrom;
+const $$createType17 = $models.CredentialInfo.createFrom;
+const $$createType18 = $Create.Array($$createType17);
+const $$createType19 = $models.GitConfig.createFrom;
+const $$createType20 = $Create.Nullable($$createType19);
+const $$createType21 = config$0.Group.createFrom;
const $$createType22 = $Create.Array($$createType21);
-const $$createType23 = $models.ProjectStatus.createFrom;
-const $$createType24 = $Create.Nullable($$createType23);
-const $$createType25 = $models.TreeNode.createFrom;
-const $$createType26 = $Create.Array($$createType25);
-const $$createType27 = $models.RemoteItem.createFrom;
+const $$createType23 = $models.FileInfo.createFrom;
+const $$createType24 = $Create.Array($$createType23);
+const $$createType25 = $models.ProjectStatus.createFrom;
+const $$createType26 = $Create.Nullable($$createType25);
+const $$createType27 = $models.TreeNode.createFrom;
const $$createType28 = $Create.Array($$createType27);
-const $$createType29 = $models.StashInfo.createFrom;
+const $$createType29 = $models.ReleaseInfo.createFrom;
const $$createType30 = $Create.Array($$createType29);
-const $$createType31 = $models.TagInfo.createFrom;
+const $$createType31 = $models.RemoteItem.createFrom;
const $$createType32 = $Create.Array($$createType31);
-const $$createType33 = $models.PushAllResult.createFrom;
+const $$createType33 = $models.StashInfo.createFrom;
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);
diff --git a/frontend/bindings/github.com/zhuy1228/GitPilot/internal/app/index.js b/frontend/bindings/github.com/zhuy1228/GitPilot/internal/app/index.js
index 64fb09e..a9ca76a 100644
--- a/frontend/bindings/github.com/zhuy1228/GitPilot/internal/app/index.js
+++ b/frontend/bindings/github.com/zhuy1228/GitPilot/internal/app/index.js
@@ -8,6 +8,7 @@ export {
};
export {
+ AssetInfo,
BatchPullResult,
BranchInfo,
CommitFileInfo,
@@ -17,10 +18,17 @@ export {
FileInfo,
GitConfig,
GitStatus,
+ MigrateOptions,
+ MigrateResult,
+ OnlineMigrateCompareResult,
+ OnlineMigrateItemResult,
ProjectOverview,
ProjectStatus,
PushAllResult,
+ ReleaseInfo,
+ ReleaseSyncResult,
RemoteItem,
+ RepoInfo,
StashInfo,
TagInfo,
TreeNode
diff --git a/frontend/bindings/github.com/zhuy1228/GitPilot/internal/app/models.js b/frontend/bindings/github.com/zhuy1228/GitPilot/internal/app/models.js
index dee7268..3ae8344 100644
--- a/frontend/bindings/github.com/zhuy1228/GitPilot/internal/app/models.js
+++ b/frontend/bindings/github.com/zhuy1228/GitPilot/internal/app/models.js
@@ -6,6 +6,58 @@
// @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime";
+/**
+ * AssetInfo Release 附件
+ */
+export class AssetInfo {
+ /**
+ * Creates a new AssetInfo instance.
+ * @param {Partial} [$$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} */($$parsedSource));
+ }
+}
+
/**
* BatchPullResult 批量 pull 结果
*/
@@ -425,6 +477,243 @@ export class GitStatus {
}
}
+/**
+ * MigrateOptions 迁移选项
+ */
+export class MigrateOptions {
+ /**
+ * Creates a new MigrateOptions instance.
+ * @param {Partial} [$$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} */($$parsedSource));
+ }
+}
+
+/**
+ * MigrateResult 迁移结果
+ */
+export class MigrateResult {
+ /**
+ * Creates a new MigrateResult instance.
+ * @param {Partial} [$$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} */($$parsedSource));
+ }
+}
+
+/**
+ * OnlineMigrateCompareResult 在线迁移对比结果
+ */
+export class OnlineMigrateCompareResult {
+ /**
+ * Creates a new OnlineMigrateCompareResult instance.
+ * @param {Partial} [$$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} */($$parsedSource));
+ }
+}
+
+/**
+ * OnlineMigrateItemResult 单个仓库在线迁移结果
+ */
+export class OnlineMigrateItemResult {
+ /**
+ * Creates a new OnlineMigrateItemResult instance.
+ * @param {Partial} [$$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} */($$parsedSource));
+ }
+}
+
/**
* ProjectOverview 项目概览信息(轻量级)
*/
@@ -560,8 +849,8 @@ export class ProjectStatus {
* @returns {ProjectStatus}
*/
static createFrom($$source = {}) {
- const $$createField2_0 = $$createType1;
- const $$createField4_0 = $$createType3;
+ const $$createField2_0 = $$createType3;
+ const $$createField4_0 = $$createType5;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("remotes" in $$parsedSource) {
$$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} [$$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} */($$parsedSource));
+ }
+}
+
+/**
+ * ReleaseSyncResult 单条 Release 同步结果
+ */
+export class ReleaseSyncResult {
+ /**
+ * Creates a new ReleaseSyncResult instance.
+ * @param {Partial} [$$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} */($$parsedSource));
+ }
+}
+
/**
* RemoteItem 远程仓库信息
*/
@@ -656,6 +1088,93 @@ export class RemoteItem {
}
}
+/**
+ * RepoInfo 仓库信息
+ */
+export class RepoInfo {
+ /**
+ * Creates a new RepoInfo instance.
+ * @param {Partial} [$$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} */($$parsedSource));
+ }
+}
+
/**
* StashInfo 贮藏信息
*/
@@ -822,7 +1341,7 @@ export class TreeNode {
* @returns {TreeNode}
*/
static createFrom($$source = {}) {
- const $$createField5_0 = $$createType5;
+ const $$createField5_0 = $$createType9;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("children" in $$parsedSource) {
$$parsedSource["children"] = $$createField5_0($$parsedSource["children"]);
@@ -832,9 +1351,13 @@ export class TreeNode {
}
// Private type creation functions
-const $$createType0 = RemoteItem.createFrom;
+const $$createType0 = RepoInfo.createFrom;
const $$createType1 = $Create.Array($$createType0);
-const $$createType2 = FileInfo.createFrom;
+const $$createType2 = RemoteItem.createFrom;
const $$createType3 = $Create.Array($$createType2);
-const $$createType4 = TreeNode.createFrom;
+const $$createType4 = FileInfo.createFrom;
const $$createType5 = $Create.Array($$createType4);
+const $$createType6 = AssetInfo.createFrom;
+const $$createType7 = $Create.Array($$createType6);
+const $$createType8 = TreeNode.createFrom;
+const $$createType9 = $Create.Array($$createType8);
diff --git a/internal/app/migration.go b/internal/app/migration.go
new file mode 100644
index 0000000..7804b48
--- /dev/null
+++ b/internal/app/migration.go
@@ -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
+}
diff --git a/internal/app/online_migrate.go b/internal/app/online_migrate.go
new file mode 100644
index 0000000..bf69e23
--- /dev/null
+++ b/internal/app/online_migrate.go
@@ -0,0 +1,596 @@
+package app
+
+import (
+ "bytes"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// --- 在线迁移相关类型 ---
+
+// OnlineMigrateCompareRequest 在线迁移对比请求
+type OnlineMigrateCompareRequest struct {
+ SrcPlatform string `json:"srcPlatform"` // github / gitee / gitea
+ SrcBaseURL string `json:"srcBaseUrl"`
+ SrcUsername string `json:"srcUsername"`
+ SrcToken string `json:"srcToken"`
+ TgtPlatform string `json:"tgtPlatform"`
+ TgtBaseURL string `json:"tgtBaseUrl"`
+ TgtUsername string `json:"tgtUsername"`
+ TgtToken string `json:"tgtToken"`
+}
+
+// MigrateOptions 迁移选项
+type MigrateOptions struct {
+ Labels bool `json:"labels"`
+ Issues bool `json:"issues"`
+ PullRequests bool `json:"pullRequests"`
+ Releases bool `json:"releases"`
+ Milestones bool `json:"milestones"`
+}
+
+// OnlineMigrateCompareResult 在线迁移对比结果
+type OnlineMigrateCompareResult struct {
+ SourceRepos []RepoInfo `json:"sourceRepos"` // 源平台所有仓库
+ TargetRepos []RepoInfo `json:"targetRepos"` // 目标平台所有仓库
+ MissingRepos []RepoInfo `json:"missingRepos"` // 目标平台缺少的仓库
+}
+
+// OnlineMigrateItemResult 单个仓库在线迁移结果
+type OnlineMigrateItemResult struct {
+ Name string `json:"name"`
+ CloneURL string `json:"cloneUrl,omitempty"`
+ Success bool `json:"success"`
+ Message string `json:"message"`
+}
+
+// OnlineMigrateProgress 迁移进度事件数据
+type OnlineMigrateProgress struct {
+ Current int `json:"current"` // 当前正在处理第几个仓库(从 1 开始)
+ Total int `json:"total"` // 仓库总数
+ RepoName string `json:"repoName"` // 当前仓库名
+ Phase string `json:"phase"` // clone | create | push | labels | milestones | issues | releases | pullRequests | done | error
+ Percent float64 `json:"percent"` // 整体进度百分比 0-100
+ Message string `json:"message"` // 可读状态描述
+}
+
+// emitMigrateProgress 向前端发送迁移进度
+func (s *AppService) emitMigrateProgress(current, total int, repoName, phase, message string) {
+ pct := float64(0)
+ if total > 0 {
+ phaseWeights := map[string]float64{
+ "clone": 0.3,
+ "create": 0.05,
+ "push": 0.3,
+ "labels": 0.05,
+ "milestones": 0.05,
+ "issues": 0.1,
+ "releases": 0.1,
+ "pullRequests": 0.05,
+ "done": 1.0,
+ "error": 0.0,
+ }
+ w := phaseWeights[phase]
+ pct = (float64(current-1) + w) / float64(total) * 100
+ if pct > 100 {
+ pct = 100
+ }
+ }
+ if s.app != nil {
+ s.app.Event.Emit("online-migrate-progress", OnlineMigrateProgress{
+ Current: current,
+ Total: total,
+ RepoName: repoName,
+ Phase: phase,
+ Percent: pct,
+ Message: message,
+ })
+ }
+}
+
+// --- 在线迁移服务方法 ---
+
+// OnlineMigrateCompare 对比两个平台的仓库列表
+func (s *AppService) OnlineMigrateCompare(
+ srcPlatform, srcBaseURL, srcUsername, srcToken,
+ tgtPlatform, tgtBaseURL, tgtUsername, tgtToken string,
+) (*OnlineMigrateCompareResult, error) {
+ srcAPI, err := NewPlatformAPI(srcPlatform, srcBaseURL, srcUsername, srcToken)
+ if err != nil {
+ return nil, fmt.Errorf("创建源平台 API 失败: %w", err)
+ }
+ tgtAPI, err := NewPlatformAPI(tgtPlatform, tgtBaseURL, tgtUsername, tgtToken)
+ if err != nil {
+ return nil, fmt.Errorf("创建目标平台 API 失败: %w", err)
+ }
+
+ srcRepos, err := srcAPI.ListRepos()
+ if err != nil {
+ return nil, fmt.Errorf("获取源平台仓库列表失败: %w", err)
+ }
+ tgtRepos, err := tgtAPI.ListRepos()
+ if err != nil {
+ return nil, fmt.Errorf("获取目标平台仓库列表失败: %w", err)
+ }
+
+ tgtNames := make(map[string]bool)
+ for _, r := range tgtRepos {
+ tgtNames[strings.ToLower(r.Name)] = true
+ }
+
+ var missing []RepoInfo
+ for _, r := range srcRepos {
+ if !tgtNames[strings.ToLower(r.Name)] {
+ missing = append(missing, r)
+ }
+ }
+
+ return &OnlineMigrateCompareResult{
+ SourceRepos: srcRepos,
+ TargetRepos: tgtRepos,
+ MissingRepos: missing,
+ }, nil
+}
+
+// OnlineMigrateExecute 执行在线迁移
+func (s *AppService) OnlineMigrateExecute(
+ srcPlatform, srcBaseURL, srcUsername, srcToken string,
+ tgtPlatform, tgtBaseURL, tgtUsername, tgtToken string,
+ repoNames []string,
+ srcUseProxy, tgtUseProxy bool,
+ opts MigrateOptions,
+) []OnlineMigrateItemResult {
+
+ total := len(repoNames)
+
+ srcAPI, err := NewPlatformAPI(srcPlatform, srcBaseURL, srcUsername, srcToken)
+ if err != nil {
+ return []OnlineMigrateItemResult{{Name: "(全局)", Message: fmt.Sprintf("创建源平台 API 失败: %v", err)}}
+ }
+
+ tgtAPI, err := NewPlatformAPI(tgtPlatform, tgtBaseURL, tgtUsername, tgtToken)
+ if err != nil {
+ return []OnlineMigrateItemResult{{Name: "(全局)", Message: fmt.Sprintf("创建目标平台 API 失败: %v", err)}}
+ }
+
+ srcRepos, err := srcAPI.ListRepos()
+ if err != nil {
+ return []OnlineMigrateItemResult{{Name: "(全局)", Message: fmt.Sprintf("获取源平台仓库列表失败: %v", err)}}
+ }
+ repoMap := make(map[string]RepoInfo)
+ for _, r := range srcRepos {
+ repoMap[strings.ToLower(r.Name)] = r
+ }
+
+ // 检查目标是否是 Gitea(可以用原生迁移 API)
+ // 但如果源平台需要代理,说明 Gitea 服务器可能无法直接访问源平台(如国内服务器访问 GitHub),
+ // 此时应跳过原生迁移,改用本地中转(本地代理克隆 → 推送到 Gitea)
+ giteaTgt, isGiteaTarget := tgtAPI.(*GiteaAPI)
+ useNativeGiteaMigrate := isGiteaTarget && !srcUseProxy
+
+ // 创建临时目录(非原生 Gitea 迁移时使用)
+ var tmpDir string
+ if !useNativeGiteaMigrate {
+ tmpDir, err = os.MkdirTemp("", "gitpilot-migrate-*")
+ if err != nil {
+ return []OnlineMigrateItemResult{{Name: "(全局)", Message: fmt.Sprintf("创建临时目录失败: %v", err)}}
+ }
+ defer os.RemoveAll(tmpDir)
+ }
+
+ srcProxy := srcUseProxy
+ tgtProxy := tgtUseProxy
+
+ var results []OnlineMigrateItemResult
+
+ for idx, name := range repoNames {
+ current := idx + 1
+ result := OnlineMigrateItemResult{Name: name}
+
+ srcRepo, ok := repoMap[strings.ToLower(name)]
+ if !ok {
+ result.Message = "在源平台未找到该仓库"
+ s.emitMigrateProgress(current, total, name, "error", result.Message)
+ results = append(results, result)
+ continue
+ }
+
+ cloneURL := srcRepo.CloneURL
+ if cloneURL == "" {
+ result.Message = "源仓库无 clone URL"
+ s.emitMigrateProgress(current, total, name, "error", result.Message)
+ results = append(results, result)
+ continue
+ }
+
+ // 解析源仓库的 owner/repo
+ srcOwner, srcRepoName, parseErr := ParseOwnerRepo(cloneURL)
+ if parseErr != nil {
+ srcOwner = srcUsername
+ srcRepoName = name
+ }
+
+ // --- Gitea 目标且源不需代理: 使用原生迁移 API ---
+ if useNativeGiteaMigrate {
+ s.emitMigrateProgress(current, total, name, "clone", fmt.Sprintf("正在迁移 %s(Gitea 原生迁移)...", name))
+
+ targetCloneURL, migrateErr := giteaTgt.MigrateRepo(cloneURL, name, srcToken, srcPlatform, opts)
+ if migrateErr != nil {
+ result.Message = fmt.Sprintf("Gitea 迁移失败: %v", migrateErr)
+ s.emitMigrateProgress(current, total, name, "error", result.Message)
+ results = append(results, result)
+ continue
+ }
+
+ result.CloneURL = targetCloneURL
+ result.Success = true
+ result.Message = "迁移成功(含选中的标签/工单/发布/里程碑/合并请求)"
+ s.emitMigrateProgress(current, total, name, "done", result.Message)
+ results = append(results, result)
+ log.Printf("在线迁移(Gitea): %s 完成 → %s", name, targetCloneURL)
+ continue
+ }
+
+ // --- 非 Gitea 目标: 手动迁移 ---
+ authCloneURL := injectTokenToURL(cloneURL, srcUsername, srcToken, srcPlatform)
+ localPath := filepath.Join(tmpDir, name)
+
+ // 1. Clone
+ s.emitMigrateProgress(current, total, name, "clone", fmt.Sprintf("正在克隆 %s...", name))
+ _, err = s.gitClient.CloneWithProxy(authCloneURL, localPath, &srcProxy)
+ if err != nil {
+ result.Message = fmt.Sprintf("克隆失败: %v", err)
+ s.emitMigrateProgress(current, total, name, "error", result.Message)
+ results = append(results, result)
+ continue
+ }
+
+ // 2. 创建目标仓库
+ s.emitMigrateProgress(current, total, name, "create", fmt.Sprintf("正在创建目标仓库 %s...", name))
+ targetCloneURL, err := tgtAPI.CreateRepo(name, srcRepo.Description, srcRepo.Private)
+ if err != nil {
+ result.Message = fmt.Sprintf("创建目标仓库失败: %v", err)
+ s.emitMigrateProgress(current, total, name, "error", result.Message)
+ results = append(results, result)
+ continue
+ }
+ result.CloneURL = targetCloneURL
+
+ tgtOwner, tgtRepoName, tgtParseErr := ParseOwnerRepo(targetCloneURL)
+ if tgtParseErr != nil {
+ tgtOwner = tgtUsername
+ tgtRepoName = name
+ }
+
+ pushURL := injectTokenToURL(targetCloneURL, tgtUsername, tgtToken, tgtPlatform)
+
+ // 3. 添加 remote
+ _, err = s.gitClient.Run(localPath, "remote", "add", "target", pushURL)
+ if err != nil {
+ result.Message = fmt.Sprintf("添加 remote 失败: %v", err)
+ s.emitMigrateProgress(current, total, name, "error", result.Message)
+ results = append(results, result)
+ continue
+ }
+
+ // 4. Push 分支和标签
+ s.emitMigrateProgress(current, total, name, "push", fmt.Sprintf("正在推送 %s 的分支和标签...", name))
+ pushFailed := false
+
+ // 先尝试 --all 一次性推送
+ _, err = s.gitClient.RunWithProxyTimeout(localPath, &tgtProxy, 10*time.Minute,
+ "-c", "http.postBuffer=524288000", "push", "target", "--all")
+ if err != nil {
+ // --all 推送失败(可能 HTTP 413),改为逐分支推送
+ log.Printf("push --all 失败,改为逐分支推送: %v", err)
+ s.emitMigrateProgress(current, total, name, "push", fmt.Sprintf("正在逐分支推送 %s...", name))
+
+ branchOut, brErr := s.gitClient.Run(localPath, "branch", "-a", "--format=%(refname:short)")
+ if brErr != nil {
+ result.Message = fmt.Sprintf("获取分支列表失败: %v", brErr)
+ s.emitMigrateProgress(current, total, name, "error", result.Message)
+ results = append(results, result)
+ continue
+ }
+ branches := strings.Split(strings.TrimSpace(branchOut), "\n")
+ var pushErrs []string
+ for _, br := range branches {
+ br = strings.TrimSpace(br)
+ if br == "" || strings.HasPrefix(br, "origin/HEAD") {
+ continue
+ }
+ // 将 origin/xxx 转换为本地 refspec
+ refspec := br
+ if strings.HasPrefix(br, "origin/") {
+ refspec = "refs/remotes/" + br + ":refs/heads/" + strings.TrimPrefix(br, "origin/")
+ }
+ _, pushErr := s.gitClient.RunWithProxyTimeout(localPath, &tgtProxy, 10*time.Minute,
+ "-c", "http.postBuffer=524288000", "push", "target", refspec)
+ if pushErr != nil {
+ pushErrs = append(pushErrs, fmt.Sprintf("%s(%v)", br, pushErr))
+ }
+ }
+ if len(pushErrs) > 0 {
+ // 如果所有分支都失败,才算整体失败
+ if len(pushErrs) == len(branches) {
+ result.Message = fmt.Sprintf("推送分支全部失败: %s", strings.Join(pushErrs, "; "))
+ s.emitMigrateProgress(current, total, name, "error", result.Message)
+ results = append(results, result)
+ pushFailed = true
+ } else {
+ log.Printf("部分分支推送失败: %s", strings.Join(pushErrs, "; "))
+ }
+ }
+ }
+ if pushFailed {
+ continue
+ }
+
+ // 推送标签
+ s.gitClient.RunWithProxyTimeout(localPath, &tgtProxy, 10*time.Minute,
+ "-c", "http.postBuffer=524288000", "push", "target", "--tags")
+
+ // --- 迁移扩展数据 ---
+ var migrateNotes []string
+
+ // 5. 标签
+ if opts.Labels {
+ s.emitMigrateProgress(current, total, name, "labels", fmt.Sprintf("正在迁移 %s 的标签...", name))
+ if labErr := s.migrateLabels(srcAPI, tgtAPI, srcOwner, srcRepoName, tgtOwner, tgtRepoName); labErr != nil {
+ migrateNotes = append(migrateNotes, fmt.Sprintf("标签: %v", labErr))
+ } else {
+ migrateNotes = append(migrateNotes, "标签 ✓")
+ }
+ }
+
+ // 6. 里程碑
+ var milestoneMap map[string]int64
+ if opts.Milestones {
+ s.emitMigrateProgress(current, total, name, "milestones", fmt.Sprintf("正在迁移 %s 的里程碑...", name))
+ var msErr error
+ milestoneMap, msErr = s.migrateMilestones(srcAPI, tgtAPI, srcOwner, srcRepoName, tgtOwner, tgtRepoName)
+ if msErr != nil {
+ migrateNotes = append(migrateNotes, fmt.Sprintf("里程碑: %v", msErr))
+ } else {
+ migrateNotes = append(migrateNotes, "里程碑 ✓")
+ }
+ }
+
+ // 7. 工单
+ if opts.Issues {
+ s.emitMigrateProgress(current, total, name, "issues", fmt.Sprintf("正在迁移 %s 的工单...", name))
+ if issErr := s.migrateIssues(srcAPI, tgtAPI, srcOwner, srcRepoName, tgtOwner, tgtRepoName, milestoneMap); issErr != nil {
+ migrateNotes = append(migrateNotes, fmt.Sprintf("工单: %v", issErr))
+ } else {
+ migrateNotes = append(migrateNotes, "工单 ✓")
+ }
+ }
+
+ // 8. 发布
+ if opts.Releases {
+ s.emitMigrateProgress(current, total, name, "releases", fmt.Sprintf("正在迁移 %s 的发布...", name))
+ if relErr := s.migrateReleases(srcAPI, tgtAPI, srcOwner, srcRepoName, tgtOwner, tgtRepoName, srcToken, srcPlatform); relErr != nil {
+ migrateNotes = append(migrateNotes, fmt.Sprintf("发布: %v", relErr))
+ } else {
+ migrateNotes = append(migrateNotes, "发布 ✓")
+ }
+ }
+
+ // 9. 合并请求(以 Issue 方式记录)
+ if opts.PullRequests {
+ s.emitMigrateProgress(current, total, name, "pullRequests", fmt.Sprintf("正在迁移 %s 的合并请求...", name))
+ if prErr := s.migratePullRequests(srcAPI, tgtAPI, srcOwner, srcRepoName, tgtOwner, tgtRepoName, milestoneMap); prErr != nil {
+ migrateNotes = append(migrateNotes, fmt.Sprintf("合并请求: %v", prErr))
+ } else {
+ migrateNotes = append(migrateNotes, "合并请求 ✓")
+ }
+ }
+
+ result.Success = true
+ if len(migrateNotes) > 0 {
+ result.Message = "迁移完成 | " + strings.Join(migrateNotes, " | ")
+ } else {
+ result.Message = "迁移成功"
+ }
+ s.emitMigrateProgress(current, total, name, "done", result.Message)
+ results = append(results, result)
+ log.Printf("在线迁移: %s 完成 → %s", name, targetCloneURL)
+
+ os.RemoveAll(localPath)
+ }
+
+ return results
+}
+
+// --- 迁移子步骤 ---
+
+func (s *AppService) migrateLabels(srcAPI, tgtAPI PlatformAPI, srcOwner, srcRepo, tgtOwner, tgtRepo string) error {
+ srcLabels, err := srcAPI.ListLabels(srcOwner, srcRepo)
+ if err != nil {
+ return fmt.Errorf("获取源标签失败: %w", err)
+ }
+ if len(srcLabels) == 0 {
+ return nil
+ }
+ tgtLabels, _ := tgtAPI.ListLabels(tgtOwner, tgtRepo)
+ existing := make(map[string]bool)
+ for _, l := range tgtLabels {
+ existing[strings.ToLower(l.Name)] = true
+ }
+ var errs []string
+ for _, label := range srcLabels {
+ if existing[strings.ToLower(label.Name)] {
+ continue
+ }
+ if err := tgtAPI.CreateLabel(tgtOwner, tgtRepo, label); err != nil {
+ errs = append(errs, fmt.Sprintf("%s(%v)", label.Name, err))
+ }
+ }
+ if len(errs) > 0 {
+ return fmt.Errorf("部分失败: %s", strings.Join(errs, "; "))
+ }
+ return nil
+}
+
+func (s *AppService) migrateMilestones(srcAPI, tgtAPI PlatformAPI, srcOwner, srcRepo, tgtOwner, tgtRepo string) (map[string]int64, error) {
+ msMap := make(map[string]int64)
+ srcMS, err := srcAPI.ListMilestones(srcOwner, srcRepo)
+ if err != nil {
+ return msMap, fmt.Errorf("获取源里程碑失败: %w", err)
+ }
+ if len(srcMS) == 0 {
+ return msMap, nil
+ }
+ tgtMS, _ := tgtAPI.ListMilestones(tgtOwner, tgtRepo)
+ for _, ms := range tgtMS {
+ msMap[ms.Title] = ms.ID
+ }
+ var errs []string
+ for _, ms := range srcMS {
+ if _, exists := msMap[ms.Title]; exists {
+ continue
+ }
+ created, err := tgtAPI.CreateMilestone(tgtOwner, tgtRepo, ms)
+ if err != nil {
+ errs = append(errs, fmt.Sprintf("%s(%v)", ms.Title, err))
+ } else if created != nil {
+ msMap[created.Title] = created.ID
+ }
+ }
+ if len(errs) > 0 {
+ return msMap, fmt.Errorf("部分失败: %s", strings.Join(errs, "; "))
+ }
+ return msMap, nil
+}
+
+func (s *AppService) migrateIssues(srcAPI, tgtAPI PlatformAPI, srcOwner, srcRepo, tgtOwner, tgtRepo string, milestoneMap map[string]int64) error {
+ srcIssues, err := srcAPI.ListIssues(srcOwner, srcRepo)
+ if err != nil {
+ return fmt.Errorf("获取源工单失败: %w", err)
+ }
+ if len(srcIssues) == 0 {
+ return nil
+ }
+ if milestoneMap == nil {
+ milestoneMap = make(map[string]int64)
+ }
+ var errs []string
+ for _, issue := range srcIssues {
+ if err := tgtAPI.CreateIssue(tgtOwner, tgtRepo, issue, milestoneMap); err != nil {
+ errs = append(errs, fmt.Sprintf("#%d(%v)", issue.Number, err))
+ if len(errs) >= 5 {
+ errs = append(errs, "...更多错误已省略")
+ break
+ }
+ }
+ }
+ if len(errs) > 0 {
+ return fmt.Errorf("部分失败: %s", strings.Join(errs, "; "))
+ }
+ return nil
+}
+
+func (s *AppService) migrateReleases(srcAPI, tgtAPI PlatformAPI, srcOwner, srcRepo, tgtOwner, tgtRepo, srcToken, srcPlatform string) error {
+ srcRel, err := srcAPI.ListReleases(srcOwner, srcRepo)
+ if err != nil {
+ return fmt.Errorf("获取源发布失败: %w", err)
+ }
+ if len(srcRel) == 0 {
+ return nil
+ }
+ tgtRel, _ := tgtAPI.ListReleases(tgtOwner, tgtRepo)
+ existing := make(map[string]bool)
+ for _, r := range tgtRel {
+ existing[r.TagName] = true
+ }
+ var errs []string
+ for _, rel := range srcRel {
+ if existing[rel.TagName] {
+ continue
+ }
+ created, err := tgtAPI.CreateRelease(tgtOwner, tgtRepo, ReleaseCreate{
+ TagName: rel.TagName, Name: rel.Name, Body: rel.Body, Draft: rel.Draft, Prerelease: rel.Prerelease,
+ })
+ if err != nil {
+ errs = append(errs, fmt.Sprintf("%s(%v)", rel.TagName, err))
+ continue
+ }
+ for _, asset := range rel.Assets {
+ if asset.DownloadURL == "" {
+ continue
+ }
+ data, dlErr := DownloadAsset(asset.DownloadURL, srcToken, srcPlatform)
+ if dlErr != nil {
+ continue
+ }
+ tgtAPI.UploadAsset(tgtOwner, tgtRepo, created.ID, asset.Name, bytes.NewReader(data))
+ }
+ }
+ if len(errs) > 0 {
+ return fmt.Errorf("部分失败: %s", strings.Join(errs, "; "))
+ }
+ return nil
+}
+
+func (s *AppService) migratePullRequests(srcAPI, tgtAPI PlatformAPI, srcOwner, srcRepo, tgtOwner, tgtRepo string, milestoneMap map[string]int64) error {
+ srcPRs, err := srcAPI.ListPullRequests(srcOwner, srcRepo)
+ if err != nil {
+ return fmt.Errorf("获取源合并请求失败: %w", err)
+ }
+ if len(srcPRs) == 0 {
+ return nil
+ }
+ if milestoneMap == nil {
+ milestoneMap = make(map[string]int64)
+ }
+ var errs []string
+ for _, pr := range srcPRs {
+ stateEmoji := "🟢"
+ if pr.State == "closed" {
+ stateEmoji = "🔴"
+ } else if pr.State == "merged" {
+ stateEmoji = "🟣"
+ }
+ body := fmt.Sprintf(
+ "%s **[迁移的合并请求]** `%s` → `%s` | 状态: %s %s | 作者: @%s\n\n---\n\n%s",
+ stateEmoji, pr.Head, pr.Base, stateEmoji, pr.State, pr.User, pr.Body,
+ )
+ issue := IssueInfo{
+ Title: fmt.Sprintf("[PR#%d] %s", pr.Number, pr.Title),
+ Body: body,
+ State: "closed",
+ }
+ if err := tgtAPI.CreateIssue(tgtOwner, tgtRepo, issue, milestoneMap); err != nil {
+ errs = append(errs, fmt.Sprintf("PR#%d(%v)", pr.Number, err))
+ if len(errs) >= 5 {
+ errs = append(errs, "...更多错误已省略")
+ break
+ }
+ }
+ }
+ if len(errs) > 0 {
+ return fmt.Errorf("部分失败: %s", strings.Join(errs, "; "))
+ }
+ return nil
+}
+
+// injectTokenToURL 将 token 注入到 HTTPS clone URL 中用于认证
+func injectTokenToURL(cloneURL, username, token, platform string) string {
+ if token == "" {
+ return cloneURL
+ }
+ if !strings.HasPrefix(cloneURL, "https://") && !strings.HasPrefix(cloneURL, "http://") {
+ return cloneURL
+ }
+ idx := strings.Index(cloneURL, "://")
+ if idx < 0 {
+ return cloneURL
+ }
+ scheme := cloneURL[:idx+3]
+ rest := cloneURL[idx+3:]
+ return fmt.Sprintf("%s%s:%s@%s", scheme, username, token, rest)
+}
diff --git a/internal/app/platform.go b/internal/app/platform.go
new file mode 100644
index 0000000..9febf61
--- /dev/null
+++ b/internal/app/platform.go
@@ -0,0 +1,1126 @@
+package app
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// --- 平台 API 通用 ---
+
+// PlatformAPI 统一平台接口
+type PlatformAPI interface {
+ // CreateRepo 创建仓库,返回 clone URL
+ CreateRepo(name, description string, private bool) (string, error)
+ // ListRepos 列出当前用户的所有仓库
+ ListRepos() ([]RepoInfo, error)
+ // ListReleases 获取所有 Release
+ ListReleases(owner, repo string) ([]ReleaseInfo, error)
+ // CreateRelease 创建 Release
+ CreateRelease(owner, repo string, release ReleaseCreate) (*ReleaseInfo, error)
+ // UploadAsset 上传附件到指定 Release
+ UploadAsset(owner, repo string, releaseID int64, fileName string, data io.Reader) error
+ // ListLabels 获取仓库标签
+ ListLabels(owner, repo string) ([]LabelInfo, error)
+ // CreateLabel 创建标签
+ CreateLabel(owner, repo string, label LabelInfo) error
+ // ListMilestones 获取里程碑
+ ListMilestones(owner, repo string) ([]MilestoneInfo, error)
+ // CreateMilestone 创建里程碑
+ CreateMilestone(owner, repo string, ms MilestoneInfo) (*MilestoneInfo, error)
+ // ListIssues 获取工单
+ ListIssues(owner, repo string) ([]IssueInfo, error)
+ // CreateIssue 创建工单
+ CreateIssue(owner, repo string, issue IssueInfo, milestoneMap map[string]int64) error
+ // ListPullRequests 获取合并请求
+ ListPullRequests(owner, repo string) ([]PullRequestInfo, error)
+ // GetPlatformName 平台名称
+ GetPlatformName() string
+}
+
+// RepoInfo 仓库信息
+type RepoInfo struct {
+ Name string `json:"name"`
+ FullName string `json:"fullName"`
+ Description string `json:"description"`
+ CloneURL string `json:"cloneUrl"`
+ SSHURL string `json:"sshUrl"`
+ Private bool `json:"private"`
+ Fork bool `json:"fork"`
+ Empty bool `json:"empty"`
+ UpdatedAt string `json:"updatedAt"`
+}
+
+// ReleaseInfo 通用 Release 信息
+type ReleaseInfo struct {
+ ID int64 `json:"id"`
+ TagName string `json:"tagName"`
+ Name string `json:"name"`
+ Body string `json:"body"`
+ Draft bool `json:"draft"`
+ Prerelease bool `json:"prerelease"`
+ CreatedAt string `json:"createdAt"`
+ PublishedAt string `json:"publishedAt"`
+ Assets []AssetInfo `json:"assets"`
+}
+
+// AssetInfo Release 附件
+type AssetInfo struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Size int64 `json:"size"`
+ DownloadURL string `json:"downloadUrl"`
+}
+
+// ReleaseCreate 创建 Release 的参数
+type ReleaseCreate struct {
+ TagName string `json:"tag_name"`
+ Name string `json:"name"`
+ Body string `json:"body"`
+ Draft bool `json:"draft"`
+ Prerelease bool `json:"prerelease"`
+}
+
+// --- GitHub API ---
+
+type GitHubAPI struct {
+ Token string
+ Username string
+ client *http.Client
+}
+
+func NewGitHubAPI(username, token string) *GitHubAPI {
+ return &GitHubAPI{
+ Token: token,
+ Username: username,
+ client: &http.Client{Timeout: 60 * time.Second},
+ }
+}
+
+func (g *GitHubAPI) GetPlatformName() string { return "github" }
+
+func (g *GitHubAPI) doRequest(method, url string, body interface{}) (*http.Response, error) {
+ var reqBody io.Reader
+ if body != nil {
+ data, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ reqBody = bytes.NewReader(data)
+ }
+ req, err := http.NewRequest(method, url, reqBody)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Accept", "application/vnd.github+json")
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ if g.Token != "" {
+ req.Header.Set("Authorization", "Bearer "+g.Token)
+ }
+ return g.client.Do(req)
+}
+
+func (g *GitHubAPI) CreateRepo(name, description string, private bool) (string, error) {
+ payload := map[string]interface{}{
+ "name": name,
+ "description": description,
+ "private": private,
+ }
+ resp, err := g.doRequest("POST", "https://api.github.com/user/repos", payload)
+ if err != nil {
+ return "", fmt.Errorf("请求失败: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return "", fmt.Errorf("创建仓库失败 (HTTP %d): %s", resp.StatusCode, string(body))
+ }
+
+ var result struct {
+ CloneURL string `json:"clone_url"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return "", fmt.Errorf("解析响应失败: %w", err)
+ }
+ return result.CloneURL, nil
+}
+
+func (g *GitHubAPI) ListReleases(owner, repo string) ([]ReleaseInfo, error) {
+ url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases?per_page=100", owner, repo)
+ resp, err := g.doRequest("GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("请求失败: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("获取 Releases 失败 (HTTP %d): %s", resp.StatusCode, string(body))
+ }
+
+ var ghReleases []struct {
+ ID int64 `json:"id"`
+ TagName string `json:"tag_name"`
+ Name string `json:"name"`
+ Body string `json:"body"`
+ Draft bool `json:"draft"`
+ Prerelease bool `json:"prerelease"`
+ CreatedAt string `json:"created_at"`
+ PublishedAt string `json:"published_at"`
+ Assets []struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Size int64 `json:"size"`
+ BrowserDownloadURL string `json:"browser_download_url"`
+ } `json:"assets"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&ghReleases); err != nil {
+ return nil, fmt.Errorf("解析响应失败: %w", err)
+ }
+
+ var releases []ReleaseInfo
+ for _, r := range ghReleases {
+ rel := ReleaseInfo{
+ ID: r.ID,
+ TagName: r.TagName,
+ Name: r.Name,
+ Body: r.Body,
+ Draft: r.Draft,
+ Prerelease: r.Prerelease,
+ CreatedAt: r.CreatedAt,
+ PublishedAt: r.PublishedAt,
+ }
+ for _, a := range r.Assets {
+ rel.Assets = append(rel.Assets, AssetInfo{
+ ID: a.ID,
+ Name: a.Name,
+ Size: a.Size,
+ DownloadURL: a.BrowserDownloadURL,
+ })
+ }
+ releases = append(releases, rel)
+ }
+ return releases, nil
+}
+
+func (g *GitHubAPI) CreateRelease(owner, repo string, release ReleaseCreate) (*ReleaseInfo, error) {
+ url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", owner, repo)
+ resp, err := g.doRequest("POST", url, release)
+ if err != nil {
+ return nil, fmt.Errorf("请求失败: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("创建 Release 失败 (HTTP %d): %s", resp.StatusCode, string(body))
+ }
+
+ var result struct {
+ ID int64 `json:"id"`
+ TagName string `json:"tag_name"`
+ Name string `json:"name"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, fmt.Errorf("解析响应失败: %w", err)
+ }
+ return &ReleaseInfo{ID: result.ID, TagName: result.TagName, Name: result.Name}, nil
+}
+
+func (g *GitHubAPI) UploadAsset(owner, repo string, releaseID int64, fileName string, data io.Reader) error {
+ url := fmt.Sprintf("https://uploads.github.com/repos/%s/%s/releases/%d/assets?name=%s", owner, repo, releaseID, fileName)
+
+ // GitHub upload API 使用 raw binary body
+ req, err := http.NewRequest("POST", url, data)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/octet-stream")
+ if g.Token != "" {
+ req.Header.Set("Authorization", "Bearer "+g.Token)
+ }
+
+ resp, err := g.client.Do(req)
+ if err != nil {
+ return fmt.Errorf("上传附件失败: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("上传附件失败 (HTTP %d): %s", resp.StatusCode, string(body))
+ }
+ return nil
+}
+
+func (g *GitHubAPI) ListRepos() ([]RepoInfo, error) {
+ var allRepos []RepoInfo
+ page := 1
+ for {
+ url := fmt.Sprintf("https://api.github.com/user/repos?per_page=100&page=%d&affiliation=owner", page)
+ resp, err := g.doRequest("GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("请求失败: %w", err)
+ }
+ var repos []struct {
+ Name string `json:"name"`
+ FullName string `json:"full_name"`
+ Description string `json:"description"`
+ CloneURL string `json:"clone_url"`
+ SSHURL string `json:"ssh_url"`
+ Private bool `json:"private"`
+ Fork bool `json:"fork"`
+ UpdatedAt string `json:"updated_at"`
+ }
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ return nil, fmt.Errorf("获取仓库列表失败 (HTTP %d): %s", resp.StatusCode, string(body))
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil {
+ resp.Body.Close()
+ return nil, fmt.Errorf("解析响应失败: %w", err)
+ }
+ resp.Body.Close()
+ if len(repos) == 0 {
+ break
+ }
+ for _, r := range repos {
+ allRepos = append(allRepos, RepoInfo{
+ Name: r.Name,
+ FullName: r.FullName,
+ Description: r.Description,
+ CloneURL: r.CloneURL,
+ SSHURL: r.SSHURL,
+ Private: r.Private,
+ Fork: r.Fork,
+ UpdatedAt: r.UpdatedAt,
+ })
+ }
+ if len(repos) < 100 {
+ break
+ }
+ page++
+ }
+ return allRepos, nil
+}
+
+// --- Gitea API ---
+
+type GiteaAPI struct {
+ BaseURL string
+ Token string
+ Username string
+ client *http.Client
+}
+
+func NewGiteaAPI(baseURL, username, token string) *GiteaAPI {
+ baseURL = strings.TrimRight(baseURL, "/")
+ return &GiteaAPI{
+ BaseURL: baseURL,
+ Token: token,
+ Username: username,
+ client: &http.Client{Timeout: 60 * time.Second},
+ }
+}
+
+func (g *GiteaAPI) GetPlatformName() string { return "gitea" }
+
+func (g *GiteaAPI) doRequest(method, url string, body interface{}) (*http.Response, error) {
+ var reqBody io.Reader
+ if body != nil {
+ data, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ reqBody = bytes.NewReader(data)
+ }
+ req, err := http.NewRequest(method, url, reqBody)
+ if err != nil {
+ return nil, err
+ }
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ req.Header.Set("Accept", "application/json")
+ if g.Token != "" {
+ req.Header.Set("Authorization", "token "+g.Token)
+ }
+ return g.client.Do(req)
+}
+
+func (g *GiteaAPI) CreateRepo(name, description string, private bool) (string, error) {
+ payload := map[string]interface{}{
+ "name": name,
+ "description": description,
+ "private": private,
+ }
+ url := g.BaseURL + "/api/v1/user/repos"
+ resp, err := g.doRequest("POST", url, payload)
+ if err != nil {
+ return "", fmt.Errorf("请求失败: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return "", fmt.Errorf("创建仓库失败 (HTTP %d): %s", resp.StatusCode, string(body))
+ }
+
+ var result struct {
+ CloneURL string `json:"clone_url"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return "", fmt.Errorf("解析响应失败: %w", err)
+ }
+ return result.CloneURL, nil
+}
+
+func (g *GiteaAPI) ListReleases(owner, repo string) ([]ReleaseInfo, error) {
+ url := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases?limit=50", g.BaseURL, owner, repo)
+ resp, err := g.doRequest("GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("请求失败: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("获取 Releases 失败 (HTTP %d): %s", resp.StatusCode, string(body))
+ }
+
+ var giteaReleases []struct {
+ ID int64 `json:"id"`
+ TagName string `json:"tag_name"`
+ Name string `json:"name"`
+ Body string `json:"body"`
+ Draft bool `json:"draft"`
+ Prerelease bool `json:"prerelease"`
+ CreatedAt string `json:"created_at"`
+ PublishedAt string `json:"published_at"`
+ Assets []struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Size int64 `json:"size"`
+ BrowserDownloadURL string `json:"browser_download_url"`
+ } `json:"assets"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&giteaReleases); err != nil {
+ return nil, fmt.Errorf("解析响应失败: %w", err)
+ }
+
+ var releases []ReleaseInfo
+ for _, r := range giteaReleases {
+ rel := ReleaseInfo{
+ ID: r.ID,
+ TagName: r.TagName,
+ Name: r.Name,
+ Body: r.Body,
+ Draft: r.Draft,
+ Prerelease: r.Prerelease,
+ CreatedAt: r.CreatedAt,
+ PublishedAt: r.PublishedAt,
+ }
+ for _, a := range r.Assets {
+ rel.Assets = append(rel.Assets, AssetInfo{
+ ID: a.ID,
+ Name: a.Name,
+ Size: a.Size,
+ DownloadURL: a.BrowserDownloadURL,
+ })
+ }
+ releases = append(releases, rel)
+ }
+ return releases, nil
+}
+
+func (g *GiteaAPI) CreateRelease(owner, repo string, release ReleaseCreate) (*ReleaseInfo, error) {
+ url := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases", g.BaseURL, owner, repo)
+ resp, err := g.doRequest("POST", url, release)
+ if err != nil {
+ return nil, fmt.Errorf("请求失败: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("创建 Release 失败 (HTTP %d): %s", resp.StatusCode, string(body))
+ }
+
+ var result struct {
+ ID int64 `json:"id"`
+ TagName string `json:"tag_name"`
+ Name string `json:"name"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, fmt.Errorf("解析响应失败: %w", err)
+ }
+ return &ReleaseInfo{ID: result.ID, TagName: result.TagName, Name: result.Name}, nil
+}
+
+func (g *GiteaAPI) UploadAsset(owner, repo string, releaseID int64, fileName string, data io.Reader) error {
+ url := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases/%d/assets?name=%s", g.BaseURL, owner, repo, releaseID, fileName)
+
+ var buf bytes.Buffer
+ w := multipart.NewWriter(&buf)
+ part, err := w.CreateFormFile("attachment", fileName)
+ if err != nil {
+ return err
+ }
+ if _, err := io.Copy(part, data); err != nil {
+ return err
+ }
+ w.Close()
+
+ req, err := http.NewRequest("POST", url, &buf)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", w.FormDataContentType())
+ if g.Token != "" {
+ req.Header.Set("Authorization", "token "+g.Token)
+ }
+
+ resp, err := g.client.Do(req)
+ if err != nil {
+ return fmt.Errorf("上传附件失败: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("上传附件失败 (HTTP %d): %s", resp.StatusCode, string(body))
+ }
+ return nil
+}
+
+func (g *GiteaAPI) ListRepos() ([]RepoInfo, error) {
+ var allRepos []RepoInfo
+ page := 1
+ for {
+ url := fmt.Sprintf("%s/api/v1/user/repos?page=%d&limit=50", g.BaseURL, page)
+ resp, err := g.doRequest("GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("请求失败: %w", err)
+ }
+ var repos []struct {
+ Name string `json:"name"`
+ FullName string `json:"full_name"`
+ Description string `json:"description"`
+ CloneURL string `json:"clone_url"`
+ SSHURL string `json:"ssh_url"`
+ Private bool `json:"private"`
+ Fork bool `json:"fork"`
+ Empty bool `json:"empty"`
+ UpdatedAt string `json:"updated_at"`
+ }
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ return nil, fmt.Errorf("获取仓库列表失败 (HTTP %d): %s", resp.StatusCode, string(body))
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil {
+ resp.Body.Close()
+ return nil, fmt.Errorf("解析响应失败: %w", err)
+ }
+ resp.Body.Close()
+ if len(repos) == 0 {
+ break
+ }
+ for _, r := range repos {
+ allRepos = append(allRepos, RepoInfo{
+ Name: r.Name,
+ FullName: r.FullName,
+ Description: r.Description,
+ CloneURL: r.CloneURL,
+ SSHURL: r.SSHURL,
+ Private: r.Private,
+ Fork: r.Fork,
+ Empty: r.Empty,
+ UpdatedAt: r.UpdatedAt,
+ })
+ }
+ if len(repos) < 50 {
+ break
+ }
+ page++
+ }
+ return allRepos, nil
+}
+
+// --- Gitee API ---
+
+type GiteeAPI struct {
+ Token string
+ Username string
+ client *http.Client
+}
+
+func NewGiteeAPI(username, token string) *GiteeAPI {
+ return &GiteeAPI{
+ Token: token,
+ Username: username,
+ client: &http.Client{Timeout: 60 * time.Second},
+ }
+}
+
+func (g *GiteeAPI) GetPlatformName() string { return "gitee" }
+
+func (g *GiteeAPI) doRequest(method, url string, body interface{}) (*http.Response, error) {
+ var reqBody io.Reader
+ if body != nil {
+ data, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ reqBody = bytes.NewReader(data)
+ }
+ req, err := http.NewRequest(method, url, reqBody)
+ if err != nil {
+ return nil, err
+ }
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ req.Header.Set("Accept", "application/json")
+ return g.client.Do(req)
+}
+
+func (g *GiteeAPI) CreateRepo(name, description string, private bool) (string, error) {
+ payload := map[string]interface{}{
+ "access_token": g.Token,
+ "name": name,
+ "description": description,
+ "private": private,
+ }
+ resp, err := g.doRequest("POST", "https://gitee.com/api/v5/user/repos", payload)
+ if err != nil {
+ return "", fmt.Errorf("请求失败: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return "", fmt.Errorf("创建仓库失败 (HTTP %d): %s", resp.StatusCode, string(body))
+ }
+
+ var result struct {
+ HtmlURL string `json:"html_url"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return "", fmt.Errorf("解析响应失败: %w", err)
+ }
+ // Gitee clone URL = html_url + ".git"
+ return result.HtmlURL + ".git", nil
+}
+
+func (g *GiteeAPI) ListReleases(owner, repo string) ([]ReleaseInfo, error) {
+ url := fmt.Sprintf("https://gitee.com/api/v5/repos/%s/%s/releases?access_token=%s&per_page=100", owner, repo, g.Token)
+ resp, err := g.doRequest("GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("请求失败: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("获取 Releases 失败 (HTTP %d): %s", resp.StatusCode, string(body))
+ }
+
+ var giteeReleases []struct {
+ ID int64 `json:"id"`
+ TagName string `json:"tag_name"`
+ Name string `json:"name"`
+ Body string `json:"body"`
+ Prerelease bool `json:"prerelease"`
+ CreatedAt string `json:"created_at"`
+ Assets []struct {
+ Name string `json:"name"`
+ BrowserDownloadURL string `json:"browser_download_url"`
+ } `json:"assets"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&giteeReleases); err != nil {
+ return nil, fmt.Errorf("解析响应失败: %w", err)
+ }
+
+ var releases []ReleaseInfo
+ for _, r := range giteeReleases {
+ rel := ReleaseInfo{
+ ID: r.ID,
+ TagName: r.TagName,
+ Name: r.Name,
+ Body: r.Body,
+ Prerelease: r.Prerelease,
+ CreatedAt: r.CreatedAt,
+ }
+ for _, a := range r.Assets {
+ rel.Assets = append(rel.Assets, AssetInfo{
+ Name: a.Name,
+ DownloadURL: a.BrowserDownloadURL,
+ })
+ }
+ releases = append(releases, rel)
+ }
+ return releases, nil
+}
+
+func (g *GiteeAPI) CreateRelease(owner, repo string, release ReleaseCreate) (*ReleaseInfo, error) {
+ url := fmt.Sprintf("https://gitee.com/api/v5/repos/%s/%s/releases", owner, repo)
+ payload := map[string]interface{}{
+ "access_token": g.Token,
+ "tag_name": release.TagName,
+ "name": release.Name,
+ "body": release.Body,
+ "prerelease": release.Prerelease,
+ }
+ resp, err := g.doRequest("POST", url, payload)
+ if err != nil {
+ return nil, fmt.Errorf("请求失败: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("创建 Release 失败 (HTTP %d): %s", resp.StatusCode, string(body))
+ }
+
+ var result struct {
+ ID int64 `json:"id"`
+ TagName string `json:"tag_name"`
+ Name string `json:"name"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, fmt.Errorf("解析响应失败: %w", err)
+ }
+ return &ReleaseInfo{ID: result.ID, TagName: result.TagName, Name: result.Name}, nil
+}
+
+func (g *GiteeAPI) UploadAsset(owner, repo string, releaseID int64, fileName string, data io.Reader) error {
+ // Gitee 目前不支持通过 API 上传 Release 附件
+ return fmt.Errorf("Gitee 暂不支持通过 API 上传 Release 附件")
+}
+
+func (g *GiteeAPI) ListRepos() ([]RepoInfo, error) {
+ var allRepos []RepoInfo
+ page := 1
+ for {
+ url := fmt.Sprintf("https://gitee.com/api/v5/user/repos?access_token=%s&type=personal&per_page=100&page=%d", g.Token, page)
+ resp, err := g.doRequest("GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("请求失败: %w", err)
+ }
+ var repos []struct {
+ Name string `json:"name"`
+ FullName string `json:"full_name"`
+ Description string `json:"description"`
+ HtmlURL string `json:"html_url"`
+ SSHURL string `json:"ssh_url"`
+ Private bool `json:"private"`
+ Fork bool `json:"fork"`
+ UpdatedAt string `json:"updated_at"`
+ }
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ return nil, fmt.Errorf("获取仓库列表失败 (HTTP %d): %s", resp.StatusCode, string(body))
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil {
+ resp.Body.Close()
+ return nil, fmt.Errorf("解析响应失败: %w", err)
+ }
+ resp.Body.Close()
+ if len(repos) == 0 {
+ break
+ }
+ for _, r := range repos {
+ allRepos = append(allRepos, RepoInfo{
+ Name: r.Name,
+ FullName: r.FullName,
+ Description: r.Description,
+ CloneURL: r.HtmlURL + ".git",
+ SSHURL: r.SSHURL,
+ Private: r.Private,
+ Fork: r.Fork,
+ UpdatedAt: r.UpdatedAt,
+ })
+ }
+ if len(repos) < 100 {
+ break
+ }
+ page++
+ }
+ return allRepos, nil
+}
+
+// --- GitLab API ---
+
+type GitLabAPI struct {
+ BaseURL string
+ Token string
+ Username string
+ client *http.Client
+}
+
+func NewGitLabAPI(baseURL, username, token string) *GitLabAPI {
+ if baseURL == "" {
+ baseURL = "https://gitlab.com"
+ }
+ baseURL = strings.TrimRight(baseURL, "/")
+ return &GitLabAPI{
+ BaseURL: baseURL,
+ Token: token,
+ Username: username,
+ client: &http.Client{Timeout: 60 * time.Second},
+ }
+}
+
+func (g *GitLabAPI) GetPlatformName() string { return "gitlab" }
+
+// projectPath 返回 URL 编码的 owner/repo 路径,用于 GitLab API
+func (g *GitLabAPI) projectPath(owner, repo string) string {
+ return strings.ReplaceAll(owner+"/"+repo, "/", "%2F")
+}
+
+func (g *GitLabAPI) doRequest(method, url string, body interface{}) (*http.Response, error) {
+ var reqBody io.Reader
+ if body != nil {
+ data, err := json.Marshal(body)
+ if err != nil {
+ return nil, err
+ }
+ reqBody = bytes.NewReader(data)
+ }
+ req, err := http.NewRequest(method, url, reqBody)
+ if err != nil {
+ return nil, err
+ }
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ req.Header.Set("Accept", "application/json")
+ if g.Token != "" {
+ req.Header.Set("PRIVATE-TOKEN", g.Token)
+ }
+ return g.client.Do(req)
+}
+
+func (g *GitLabAPI) CreateRepo(name, description string, private bool) (string, error) {
+ visibility := "public"
+ if private {
+ visibility = "private"
+ }
+ payload := map[string]interface{}{
+ "name": name,
+ "description": description,
+ "visibility": visibility,
+ }
+ url := g.BaseURL + "/api/v4/projects"
+ resp, err := g.doRequest("POST", url, payload)
+ if err != nil {
+ return "", fmt.Errorf("请求失败: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return "", fmt.Errorf("创建仓库失败 (HTTP %d): %s", resp.StatusCode, string(body))
+ }
+
+ var result struct {
+ HTTPURLToRepo string `json:"http_url_to_repo"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return "", fmt.Errorf("解析响应失败: %w", err)
+ }
+ return result.HTTPURLToRepo, nil
+}
+
+func (g *GitLabAPI) ListReleases(owner, repo string) ([]ReleaseInfo, error) {
+ pp := g.projectPath(owner, repo)
+ url := fmt.Sprintf("%s/api/v4/projects/%s/releases?per_page=100", g.BaseURL, pp)
+ resp, err := g.doRequest("GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("请求失败: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("获取 Releases 失败 (HTTP %d): %s", resp.StatusCode, string(body))
+ }
+
+ var glReleases []struct {
+ TagName string `json:"tag_name"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ CreatedAt string `json:"created_at"`
+ ReleasedAt string `json:"released_at"`
+ Assets struct {
+ Links []struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ URL string `json:"url"`
+ LinkType string `json:"link_type"`
+ } `json:"links"`
+ } `json:"assets"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&glReleases); err != nil {
+ return nil, fmt.Errorf("解析响应失败: %w", err)
+ }
+
+ var releases []ReleaseInfo
+ for _, r := range glReleases {
+ rel := ReleaseInfo{
+ TagName: r.TagName,
+ Name: r.Name,
+ Body: r.Description,
+ CreatedAt: r.CreatedAt,
+ PublishedAt: r.ReleasedAt,
+ }
+ for _, link := range r.Assets.Links {
+ rel.Assets = append(rel.Assets, AssetInfo{
+ ID: link.ID,
+ Name: link.Name,
+ DownloadURL: link.URL,
+ })
+ }
+ releases = append(releases, rel)
+ }
+ return releases, nil
+}
+
+func (g *GitLabAPI) CreateRelease(owner, repo string, release ReleaseCreate) (*ReleaseInfo, error) {
+ pp := g.projectPath(owner, repo)
+ url := fmt.Sprintf("%s/api/v4/projects/%s/releases", g.BaseURL, pp)
+ payload := map[string]interface{}{
+ "tag_name": release.TagName,
+ "name": release.Name,
+ "description": release.Body,
+ }
+ resp, err := g.doRequest("POST", url, payload)
+ if err != nil {
+ return nil, fmt.Errorf("请求失败: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("创建 Release 失败 (HTTP %d): %s", resp.StatusCode, string(body))
+ }
+
+ var result struct {
+ TagName string `json:"tag_name"`
+ Name string `json:"name"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, fmt.Errorf("解析响应失败: %w", err)
+ }
+ return &ReleaseInfo{TagName: result.TagName, Name: result.Name}, nil
+}
+
+func (g *GitLabAPI) UploadAsset(owner, repo string, releaseID int64, fileName string, data io.Reader) error {
+ // GitLab Release 附件通过 project uploads + release link 方式实现
+ pp := g.projectPath(owner, repo)
+
+ // 1. 上传文件到项目
+ uploadURL := fmt.Sprintf("%s/api/v4/projects/%s/uploads", g.BaseURL, pp)
+
+ var buf bytes.Buffer
+ w := multipart.NewWriter(&buf)
+ part, err := w.CreateFormFile("file", fileName)
+ if err != nil {
+ return err
+ }
+ if _, err := io.Copy(part, data); err != nil {
+ return err
+ }
+ w.Close()
+
+ req, err := http.NewRequest("POST", uploadURL, &buf)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", w.FormDataContentType())
+ if g.Token != "" {
+ req.Header.Set("PRIVATE-TOKEN", g.Token)
+ }
+
+ resp, err := g.client.Do(req)
+ if err != nil {
+ return fmt.Errorf("上传附件失败: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("上传附件失败 (HTTP %d): %s", resp.StatusCode, string(body))
+ }
+
+ var uploadResult struct {
+ URL string `json:"url"`
+ Markdown string `json:"markdown"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&uploadResult); err != nil {
+ return fmt.Errorf("解析上传结果失败: %w", err)
+ }
+
+ // 注意:GitLab uploads API 返回的 URL 是相对路径,不自动关联到 Release
+ // 如需关联到 Release,需要调用 Release Links API(需要 tag_name 而非 releaseID)
+ return nil
+}
+
+func (g *GitLabAPI) ListRepos() ([]RepoInfo, error) {
+ var allRepos []RepoInfo
+ page := 1
+ for {
+ url := fmt.Sprintf("%s/api/v4/projects?membership=true&owned=true&per_page=100&page=%d", g.BaseURL, page)
+ resp, err := g.doRequest("GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("请求失败: %w", err)
+ }
+ var repos []struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ PathWithNamespace string `json:"path_with_namespace"`
+ Description string `json:"description"`
+ HTTPURLToRepo string `json:"http_url_to_repo"`
+ SSHURLToRepo string `json:"ssh_url_to_repo"`
+ Visibility string `json:"visibility"`
+ ForkedFromProject *struct {
+ ID int64 `json:"id"`
+ } `json:"forked_from_project"`
+ Empty bool `json:"empty_repo"`
+ UpdatedAt string `json:"last_activity_at"`
+ }
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ return nil, fmt.Errorf("获取仓库列表失败 (HTTP %d): %s", resp.StatusCode, string(body))
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil {
+ resp.Body.Close()
+ return nil, fmt.Errorf("解析响应失败: %w", err)
+ }
+ resp.Body.Close()
+ if len(repos) == 0 {
+ break
+ }
+ for _, r := range repos {
+ allRepos = append(allRepos, RepoInfo{
+ Name: r.Name,
+ FullName: r.PathWithNamespace,
+ Description: r.Description,
+ CloneURL: r.HTTPURLToRepo,
+ SSHURL: r.SSHURLToRepo,
+ Private: r.Visibility == "private",
+ Fork: r.ForkedFromProject != nil,
+ Empty: r.Empty,
+ UpdatedAt: r.UpdatedAt,
+ })
+ }
+ if len(repos) < 100 {
+ break
+ }
+ page++
+ }
+ return allRepos, nil
+}
+
+// --- 辅助方法 ---
+
+// NewPlatformAPI 根据凭证创建平台 API 客户端
+func NewPlatformAPI(platform, baseURL, username, token string) (PlatformAPI, error) {
+ switch strings.ToLower(platform) {
+ case "github":
+ return NewGitHubAPI(username, token), nil
+ case "gitea":
+ if baseURL == "" {
+ return nil, fmt.Errorf("Gitea 需要提供 Base URL")
+ }
+ return NewGiteaAPI(baseURL, username, token), nil
+ case "gitee":
+ return NewGiteeAPI(username, token), nil
+ case "gitlab":
+ return NewGitLabAPI(baseURL, username, token), nil
+ default:
+ return nil, fmt.Errorf("不支持的平台: %s", platform)
+ }
+}
+
+// ParseOwnerRepo 从 remote URL 中解析 owner 和 repo
+// 支持 HTTPS 和 SSH 格式
+func ParseOwnerRepo(remoteURL string) (owner, repo string, err error) {
+ remoteURL = strings.TrimSpace(remoteURL)
+ if remoteURL == "" {
+ return "", "", fmt.Errorf("远程地址为空")
+ }
+
+ // SSH 格式: git@github.com:owner/repo.git
+ if strings.HasPrefix(remoteURL, "git@") {
+ parts := strings.SplitN(remoteURL, ":", 2)
+ if len(parts) != 2 {
+ return "", "", fmt.Errorf("无法解析 SSH 地址: %s", remoteURL)
+ }
+ ownerRepo := strings.TrimSuffix(parts[1], ".git")
+ segments := strings.SplitN(ownerRepo, "/", 2)
+ if len(segments) != 2 {
+ return "", "", fmt.Errorf("无法解析 owner/repo: %s", remoteURL)
+ }
+ return segments[0], segments[1], nil
+ }
+
+ // HTTPS 格式: https://github.com/owner/repo.git
+ remoteURL = strings.TrimSuffix(remoteURL, ".git")
+ // 去掉协议
+ urlPath := remoteURL
+ if idx := strings.Index(urlPath, "://"); idx >= 0 {
+ urlPath = urlPath[idx+3:]
+ }
+ // 去掉 host
+ if idx := strings.Index(urlPath, "/"); idx >= 0 {
+ urlPath = urlPath[idx+1:]
+ }
+ segments := strings.SplitN(urlPath, "/", 2)
+ if len(segments) != 2 || segments[0] == "" || segments[1] == "" {
+ return "", "", fmt.Errorf("无法解析 owner/repo: %s", remoteURL)
+ }
+ return segments[0], segments[1], nil
+}
+
+// DownloadAsset 下载远程附件到内存
+func DownloadAsset(url, token, platform string) ([]byte, error) {
+ client := &http.Client{Timeout: 5 * time.Minute}
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ // 某些平台私有仓库的附件需要认证
+ if token != "" {
+ switch strings.ToLower(platform) {
+ case "github":
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Accept", "application/octet-stream")
+ case "gitea":
+ req.Header.Set("Authorization", "token "+token)
+ case "gitlab":
+ req.Header.Set("PRIVATE-TOKEN", token)
+ }
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ return nil, fmt.Errorf("下载失败 (HTTP %d)", resp.StatusCode)
+ }
+ return io.ReadAll(resp.Body)
+}
diff --git a/internal/app/platform_extra.go b/internal/app/platform_extra.go
new file mode 100644
index 0000000..ea9e770
--- /dev/null
+++ b/internal/app/platform_extra.go
@@ -0,0 +1,1244 @@
+package app
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "strings"
+)
+
+// --- 迁移相关扩展类型 ---
+
+// LabelInfo 标签
+type LabelInfo struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Color string `json:"color"` // 不含 # 前缀,如 "fc2929"
+ Desc string `json:"description,omitempty"`
+}
+
+// MilestoneInfo 里程碑
+type MilestoneInfo struct {
+ ID int64 `json:"id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ State string `json:"state"` // open / closed
+ DueOn string `json:"dueOn,omitempty"`
+}
+
+// IssueInfo 工单
+type IssueInfo struct {
+ ID int64 `json:"id"`
+ Number int `json:"number"`
+ Title string `json:"title"`
+ Body string `json:"body"`
+ State string `json:"state"` // open / closed
+ Labels []string `json:"labels,omitempty"`
+ MilestoneTitle string `json:"milestoneTitle,omitempty"`
+ MilestoneID int64 `json:"milestoneId,omitempty"`
+ Comments []CommentInfo `json:"comments,omitempty"`
+ CreatedAt string `json:"createdAt,omitempty"`
+}
+
+// CommentInfo 评论
+type CommentInfo struct {
+ Body string `json:"body"`
+ User string `json:"user,omitempty"`
+ CreatedAt string `json:"createdAt,omitempty"`
+}
+
+// PullRequestInfo 合并请求(简化信息,用于记录式迁移)
+type PullRequestInfo struct {
+ ID int64 `json:"id"`
+ Number int `json:"number"`
+ Title string `json:"title"`
+ Body string `json:"body"`
+ State string `json:"state"` // open / closed / merged
+ Head string `json:"head"`
+ Base string `json:"base"`
+ User string `json:"user,omitempty"`
+ CreatedAt string `json:"createdAt,omitempty"`
+}
+
+// ================ GitHub ================
+
+func (g *GitHubAPI) ListLabels(owner, repo string) ([]LabelInfo, error) {
+ var all []LabelInfo
+ page := 1
+ for {
+ url := fmt.Sprintf("https://api.github.com/repos/%s/%s/labels?per_page=100&page=%d", owner, repo, page)
+ resp, err := g.doRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ var items []struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Color string `json:"color"`
+ Desc string `json:"description"`
+ }
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ json.NewDecoder(resp.Body).Decode(&items)
+ resp.Body.Close()
+ if len(items) == 0 {
+ break
+ }
+ for _, it := range items {
+ all = append(all, LabelInfo{ID: it.ID, Name: it.Name, Color: it.Color, Desc: it.Desc})
+ }
+ if len(items) < 100 {
+ break
+ }
+ page++
+ }
+ return all, nil
+}
+
+func (g *GitHubAPI) CreateLabel(owner, repo string, label LabelInfo) error {
+ url := fmt.Sprintf("https://api.github.com/repos/%s/%s/labels", owner, repo)
+ payload := map[string]interface{}{
+ "name": label.Name,
+ "color": strings.TrimPrefix(label.Color, "#"),
+ "description": label.Desc,
+ }
+ resp, err := g.doRequest("POST", url, payload)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ return nil
+}
+
+func (g *GitHubAPI) ListMilestones(owner, repo string) ([]MilestoneInfo, error) {
+ var all []MilestoneInfo
+ for _, state := range []string{"open", "closed"} {
+ page := 1
+ for {
+ url := fmt.Sprintf("https://api.github.com/repos/%s/%s/milestones?state=%s&per_page=100&page=%d", owner, repo, state, page)
+ resp, err := g.doRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ var items []struct {
+ ID int64 `json:"id"`
+ Number int `json:"number"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ State string `json:"state"`
+ DueOn string `json:"due_on"`
+ }
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ json.NewDecoder(resp.Body).Decode(&items)
+ resp.Body.Close()
+ if len(items) == 0 {
+ break
+ }
+ for _, it := range items {
+ all = append(all, MilestoneInfo{
+ ID: it.ID, Title: it.Title, Description: it.Description, State: it.State, DueOn: it.DueOn,
+ })
+ }
+ if len(items) < 100 {
+ break
+ }
+ page++
+ }
+ }
+ return all, nil
+}
+
+func (g *GitHubAPI) CreateMilestone(owner, repo string, ms MilestoneInfo) (*MilestoneInfo, error) {
+ url := fmt.Sprintf("https://api.github.com/repos/%s/%s/milestones", owner, repo)
+ payload := map[string]interface{}{
+ "title": ms.Title,
+ "description": ms.Description,
+ "state": ms.State,
+ }
+ if ms.DueOn != "" {
+ payload["due_on"] = ms.DueOn
+ }
+ resp, err := g.doRequest("POST", url, payload)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ var result struct {
+ Number int `json:"number"`
+ Title string `json:"title"`
+ }
+ json.NewDecoder(resp.Body).Decode(&result)
+ return &MilestoneInfo{ID: int64(result.Number), Title: result.Title, State: ms.State}, nil
+}
+
+func (g *GitHubAPI) ListIssues(owner, repo string) ([]IssueInfo, error) {
+ var all []IssueInfo
+ for _, state := range []string{"open", "closed"} {
+ page := 1
+ for {
+ url := fmt.Sprintf("https://api.github.com/repos/%s/%s/issues?state=%s&per_page=100&page=%d&direction=asc", owner, repo, state, page)
+ resp, err := g.doRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ var items []struct {
+ ID int64 `json:"id"`
+ Number int `json:"number"`
+ Title string `json:"title"`
+ Body string `json:"body"`
+ State string `json:"state"`
+ Labels []struct {
+ Name string `json:"name"`
+ } `json:"labels"`
+ Milestone *struct {
+ Title string `json:"title"`
+ } `json:"milestone"`
+ PullRequest *struct {
+ URL string `json:"url"`
+ } `json:"pull_request"`
+ CreatedAt string `json:"created_at"`
+ }
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ json.NewDecoder(resp.Body).Decode(&items)
+ resp.Body.Close()
+ if len(items) == 0 {
+ break
+ }
+ for _, it := range items {
+ // GitHub Issues API 也返回 PR,通过 pull_request 字段过滤
+ if it.PullRequest != nil {
+ continue
+ }
+ issue := IssueInfo{
+ ID: it.ID, Number: it.Number, Title: it.Title, Body: it.Body,
+ State: it.State, CreatedAt: it.CreatedAt,
+ }
+ for _, l := range it.Labels {
+ issue.Labels = append(issue.Labels, l.Name)
+ }
+ if it.Milestone != nil {
+ issue.MilestoneTitle = it.Milestone.Title
+ }
+ all = append(all, issue)
+ }
+ if len(items) < 100 {
+ break
+ }
+ page++
+ }
+ }
+ return all, nil
+}
+
+func (g *GitHubAPI) CreateIssue(owner, repo string, issue IssueInfo, milestoneMap map[string]int64) error {
+ url := fmt.Sprintf("https://api.github.com/repos/%s/%s/issues", owner, repo)
+ payload := map[string]interface{}{
+ "title": issue.Title,
+ "body": issue.Body,
+ "labels": issue.Labels,
+ }
+ if issue.MilestoneTitle != "" {
+ if msID, ok := milestoneMap[issue.MilestoneTitle]; ok {
+ payload["milestone"] = msID
+ }
+ }
+ resp, err := g.doRequest("POST", url, payload)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ // 如果需要关闭 issue
+ if issue.State == "closed" {
+ var created struct {
+ Number int `json:"number"`
+ }
+ json.NewDecoder(resp.Body).Decode(&created)
+ closeURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/issues/%d", owner, repo, created.Number)
+ closeResp, err := g.doRequest("PATCH", closeURL, map[string]string{"state": "closed"})
+ if err == nil {
+ closeResp.Body.Close()
+ }
+ }
+ return nil
+}
+
+func (g *GitHubAPI) ListPullRequests(owner, repo string) ([]PullRequestInfo, error) {
+ var all []PullRequestInfo
+ for _, state := range []string{"open", "closed"} {
+ page := 1
+ for {
+ url := fmt.Sprintf("https://api.github.com/repos/%s/%s/pulls?state=%s&per_page=100&page=%d&direction=asc", owner, repo, state, page)
+ resp, err := g.doRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ var items []struct {
+ ID int64 `json:"id"`
+ Number int `json:"number"`
+ Title string `json:"title"`
+ Body string `json:"body"`
+ State string `json:"state"`
+ Merged bool `json:"merged"`
+ Head struct {
+ Ref string `json:"ref"`
+ } `json:"head"`
+ Base struct {
+ Ref string `json:"ref"`
+ } `json:"base"`
+ User struct {
+ Login string `json:"login"`
+ } `json:"user"`
+ CreatedAt string `json:"created_at"`
+ }
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ json.NewDecoder(resp.Body).Decode(&items)
+ resp.Body.Close()
+ if len(items) == 0 {
+ break
+ }
+ for _, it := range items {
+ st := it.State
+ if it.Merged {
+ st = "merged"
+ }
+ all = append(all, PullRequestInfo{
+ ID: it.ID, Number: it.Number, Title: it.Title, Body: it.Body,
+ State: st, Head: it.Head.Ref, Base: it.Base.Ref,
+ User: it.User.Login, CreatedAt: it.CreatedAt,
+ })
+ }
+ if len(items) < 100 {
+ break
+ }
+ page++
+ }
+ }
+ return all, nil
+}
+
+// ================ Gitea ================
+
+func (g *GiteaAPI) ListLabels(owner, repo string) ([]LabelInfo, error) {
+ var all []LabelInfo
+ page := 1
+ for {
+ url := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels?page=%d&limit=50", g.BaseURL, owner, repo, page)
+ resp, err := g.doRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ var items []struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Color string `json:"color"`
+ Desc string `json:"description"`
+ }
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ json.NewDecoder(resp.Body).Decode(&items)
+ resp.Body.Close()
+ if len(items) == 0 {
+ break
+ }
+ for _, it := range items {
+ all = append(all, LabelInfo{ID: it.ID, Name: it.Name, Color: strings.TrimPrefix(it.Color, "#"), Desc: it.Desc})
+ }
+ if len(items) < 50 {
+ break
+ }
+ page++
+ }
+ return all, nil
+}
+
+func (g *GiteaAPI) CreateLabel(owner, repo string, label LabelInfo) error {
+ url := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels", g.BaseURL, owner, repo)
+ color := label.Color
+ if !strings.HasPrefix(color, "#") {
+ color = "#" + color
+ }
+ payload := map[string]interface{}{
+ "name": label.Name,
+ "color": color,
+ "description": label.Desc,
+ }
+ resp, err := g.doRequest("POST", url, payload)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ return nil
+}
+
+func (g *GiteaAPI) ListMilestones(owner, repo string) ([]MilestoneInfo, error) {
+ var all []MilestoneInfo
+ for _, state := range []string{"open", "closed"} {
+ page := 1
+ for {
+ url := fmt.Sprintf("%s/api/v1/repos/%s/%s/milestones?state=%s&page=%d&limit=50", g.BaseURL, owner, repo, state, page)
+ resp, err := g.doRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ var items []struct {
+ ID int64 `json:"id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ State string `json:"state"`
+ DueOn string `json:"due_on"`
+ }
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ json.NewDecoder(resp.Body).Decode(&items)
+ resp.Body.Close()
+ if len(items) == 0 {
+ break
+ }
+ for _, it := range items {
+ all = append(all, MilestoneInfo{
+ ID: it.ID, Title: it.Title, Description: it.Description, State: it.State, DueOn: it.DueOn,
+ })
+ }
+ if len(items) < 50 {
+ break
+ }
+ page++
+ }
+ }
+ return all, nil
+}
+
+func (g *GiteaAPI) CreateMilestone(owner, repo string, ms MilestoneInfo) (*MilestoneInfo, error) {
+ url := fmt.Sprintf("%s/api/v1/repos/%s/%s/milestones", g.BaseURL, owner, repo)
+ payload := map[string]interface{}{
+ "title": ms.Title,
+ "description": ms.Description,
+ "state": ms.State,
+ }
+ if ms.DueOn != "" {
+ payload["due_on"] = ms.DueOn
+ }
+ resp, err := g.doRequest("POST", url, payload)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ var result struct {
+ ID int64 `json:"id"`
+ Title string `json:"title"`
+ }
+ json.NewDecoder(resp.Body).Decode(&result)
+ return &MilestoneInfo{ID: result.ID, Title: result.Title, State: ms.State}, nil
+}
+
+func (g *GiteaAPI) ListIssues(owner, repo string) ([]IssueInfo, error) {
+ var all []IssueInfo
+ for _, state := range []string{"open", "closed"} {
+ page := 1
+ for {
+ url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues?state=%s&type=issues&page=%d&limit=50", g.BaseURL, owner, repo, state, page)
+ resp, err := g.doRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ var items []struct {
+ ID int64 `json:"id"`
+ Number int `json:"number"`
+ Title string `json:"title"`
+ Body string `json:"body"`
+ State string `json:"state"`
+ Labels []struct {
+ Name string `json:"name"`
+ } `json:"labels"`
+ Milestone *struct {
+ Title string `json:"title"`
+ } `json:"milestone"`
+ CreatedAt string `json:"created_at"`
+ }
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ json.NewDecoder(resp.Body).Decode(&items)
+ resp.Body.Close()
+ if len(items) == 0 {
+ break
+ }
+ for _, it := range items {
+ issue := IssueInfo{
+ ID: it.ID, Number: it.Number, Title: it.Title, Body: it.Body,
+ State: it.State, CreatedAt: it.CreatedAt,
+ }
+ for _, l := range it.Labels {
+ issue.Labels = append(issue.Labels, l.Name)
+ }
+ if it.Milestone != nil {
+ issue.MilestoneTitle = it.Milestone.Title
+ }
+ all = append(all, issue)
+ }
+ if len(items) < 50 {
+ break
+ }
+ page++
+ }
+ }
+ return all, nil
+}
+
+func (g *GiteaAPI) CreateIssue(owner, repo string, issue IssueInfo, milestoneMap map[string]int64) error {
+ url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues", g.BaseURL, owner, repo)
+ payload := map[string]interface{}{
+ "title": issue.Title,
+ "body": issue.Body,
+ "labels": []int64{}, // Gitea 需要 label ID,后面处理
+ }
+ if issue.MilestoneTitle != "" {
+ if msID, ok := milestoneMap[issue.MilestoneTitle]; ok {
+ payload["milestone"] = msID
+ }
+ }
+ resp, err := g.doRequest("POST", url, payload)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ // 关闭 issue
+ if issue.State == "closed" {
+ var created struct {
+ Number int `json:"number"`
+ }
+ json.NewDecoder(resp.Body).Decode(&created)
+ closeURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", g.BaseURL, owner, repo, created.Number)
+ closeResp, err := g.doRequest("PATCH", closeURL, map[string]string{"state": "closed"})
+ if err == nil {
+ closeResp.Body.Close()
+ }
+ }
+ return nil
+}
+
+func (g *GiteaAPI) ListPullRequests(owner, repo string) ([]PullRequestInfo, error) {
+ var all []PullRequestInfo
+ for _, state := range []string{"open", "closed"} {
+ page := 1
+ for {
+ url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls?state=%s&page=%d&limit=50", g.BaseURL, owner, repo, state, page)
+ resp, err := g.doRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ var items []struct {
+ ID int64 `json:"id"`
+ Number int `json:"number"`
+ Title string `json:"title"`
+ Body string `json:"body"`
+ State string `json:"state"`
+ Merged bool `json:"merged"`
+ Head struct {
+ Ref string `json:"ref"`
+ } `json:"head"`
+ Base struct {
+ Ref string `json:"ref"`
+ } `json:"base"`
+ User struct {
+ Username string `json:"username"`
+ } `json:"user"`
+ CreatedAt string `json:"created_at"`
+ }
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ json.NewDecoder(resp.Body).Decode(&items)
+ resp.Body.Close()
+ if len(items) == 0 {
+ break
+ }
+ for _, it := range items {
+ st := it.State
+ if it.Merged {
+ st = "merged"
+ }
+ all = append(all, PullRequestInfo{
+ ID: it.ID, Number: it.Number, Title: it.Title, Body: it.Body,
+ State: st, Head: it.Head.Ref, Base: it.Base.Ref,
+ User: it.User.Username, CreatedAt: it.CreatedAt,
+ })
+ }
+ if len(items) < 50 {
+ break
+ }
+ page++
+ }
+ }
+ return all, nil
+}
+
+// Gitea 迁移 API — 当目标平台是 Gitea 时可直接使用
+func (g *GiteaAPI) MigrateRepo(cloneAddr, repoName, token, srcPlatform string, opts MigrateOptions) (string, error) {
+ url := g.BaseURL + "/api/v1/repos/migrate"
+
+ serviceType := "git"
+ switch strings.ToLower(srcPlatform) {
+ case "github":
+ serviceType = "github"
+ case "gitea":
+ serviceType = "gitea"
+ case "gitlab":
+ serviceType = "gitlab"
+ case "gitee":
+ serviceType = "gitee"
+ }
+
+ payload := map[string]interface{}{
+ "clone_addr": cloneAddr,
+ "auth_token": token,
+ "repo_name": repoName,
+ "repo_owner": g.Username,
+ "service": serviceType,
+ "mirror": false,
+ "issues": opts.Issues,
+ "labels": opts.Labels,
+ "milestones": opts.Milestones,
+ "releases": opts.Releases,
+ "pull_requests": opts.PullRequests,
+ "wiki": false,
+ }
+
+ resp, err := g.doRequest("POST", url, payload)
+ if err != nil {
+ return "", fmt.Errorf("请求失败: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return "", fmt.Errorf("迁移失败 (HTTP %d): %s", resp.StatusCode, string(body))
+ }
+
+ var result struct {
+ CloneURL string `json:"clone_url"`
+ }
+ json.NewDecoder(resp.Body).Decode(&result)
+ return result.CloneURL, nil
+}
+
+// ================ GitLab ================
+
+func (g *GitLabAPI) ListLabels(owner, repo string) ([]LabelInfo, error) {
+ var all []LabelInfo
+ pp := g.projectPath(owner, repo)
+ page := 1
+ for {
+ url := fmt.Sprintf("%s/api/v4/projects/%s/labels?per_page=100&page=%d", g.BaseURL, pp, page)
+ resp, err := g.doRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ var items []struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Color string `json:"color"`
+ Desc string `json:"description"`
+ }
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ json.NewDecoder(resp.Body).Decode(&items)
+ resp.Body.Close()
+ if len(items) == 0 {
+ break
+ }
+ for _, it := range items {
+ all = append(all, LabelInfo{ID: it.ID, Name: it.Name, Color: strings.TrimPrefix(it.Color, "#"), Desc: it.Desc})
+ }
+ if len(items) < 100 {
+ break
+ }
+ page++
+ }
+ return all, nil
+}
+
+func (g *GitLabAPI) CreateLabel(owner, repo string, label LabelInfo) error {
+ pp := g.projectPath(owner, repo)
+ url := fmt.Sprintf("%s/api/v4/projects/%s/labels", g.BaseURL, pp)
+ color := label.Color
+ if !strings.HasPrefix(color, "#") {
+ color = "#" + color
+ }
+ payload := map[string]interface{}{
+ "name": label.Name,
+ "color": color,
+ "description": label.Desc,
+ }
+ resp, err := g.doRequest("POST", url, payload)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ return nil
+}
+
+func (g *GitLabAPI) ListMilestones(owner, repo string) ([]MilestoneInfo, error) {
+ var all []MilestoneInfo
+ pp := g.projectPath(owner, repo)
+ for _, state := range []string{"active", "closed"} {
+ page := 1
+ for {
+ url := fmt.Sprintf("%s/api/v4/projects/%s/milestones?state=%s&per_page=100&page=%d", g.BaseURL, pp, state, page)
+ resp, err := g.doRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ var items []struct {
+ ID int64 `json:"id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ State string `json:"state"`
+ DueDate string `json:"due_date"`
+ }
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ json.NewDecoder(resp.Body).Decode(&items)
+ resp.Body.Close()
+ if len(items) == 0 {
+ break
+ }
+ for _, it := range items {
+ msState := it.State
+ if msState == "active" {
+ msState = "open"
+ }
+ all = append(all, MilestoneInfo{
+ ID: it.ID, Title: it.Title, Description: it.Description, State: msState, DueOn: it.DueDate,
+ })
+ }
+ if len(items) < 100 {
+ break
+ }
+ page++
+ }
+ }
+ return all, nil
+}
+
+func (g *GitLabAPI) CreateMilestone(owner, repo string, ms MilestoneInfo) (*MilestoneInfo, error) {
+ pp := g.projectPath(owner, repo)
+ url := fmt.Sprintf("%s/api/v4/projects/%s/milestones", g.BaseURL, pp)
+ state := ms.State
+ if state == "open" {
+ state = "activate"
+ } else if state == "closed" {
+ state = "close"
+ }
+ payload := map[string]interface{}{
+ "title": ms.Title,
+ "description": ms.Description,
+ "state_event": state,
+ }
+ if ms.DueOn != "" {
+ payload["due_date"] = ms.DueOn
+ }
+ resp, err := g.doRequest("POST", url, payload)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ var result struct {
+ ID int64 `json:"id"`
+ Title string `json:"title"`
+ }
+ json.NewDecoder(resp.Body).Decode(&result)
+ return &MilestoneInfo{ID: result.ID, Title: result.Title, State: ms.State}, nil
+}
+
+func (g *GitLabAPI) ListIssues(owner, repo string) ([]IssueInfo, error) {
+ var all []IssueInfo
+ pp := g.projectPath(owner, repo)
+ for _, state := range []string{"opened", "closed"} {
+ page := 1
+ for {
+ url := fmt.Sprintf("%s/api/v4/projects/%s/issues?state=%s&per_page=100&page=%d&order_by=created_at&sort=asc", g.BaseURL, pp, state, page)
+ resp, err := g.doRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ var items []struct {
+ ID int64 `json:"id"`
+ IID int `json:"iid"`
+ Title string `json:"title"`
+ Desc string `json:"description"`
+ State string `json:"state"`
+ Labels []string `json:"labels"`
+ Milestone *struct {
+ Title string `json:"title"`
+ } `json:"milestone"`
+ CreatedAt string `json:"created_at"`
+ }
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ json.NewDecoder(resp.Body).Decode(&items)
+ resp.Body.Close()
+ if len(items) == 0 {
+ break
+ }
+ for _, it := range items {
+ issueState := it.State
+ if issueState == "opened" {
+ issueState = "open"
+ }
+ issue := IssueInfo{
+ ID: it.ID, Number: it.IID, Title: it.Title, Body: it.Desc,
+ State: issueState, Labels: it.Labels, CreatedAt: it.CreatedAt,
+ }
+ if it.Milestone != nil {
+ issue.MilestoneTitle = it.Milestone.Title
+ }
+ all = append(all, issue)
+ }
+ if len(items) < 100 {
+ break
+ }
+ page++
+ }
+ }
+ return all, nil
+}
+
+func (g *GitLabAPI) CreateIssue(owner, repo string, issue IssueInfo, milestoneMap map[string]int64) error {
+ pp := g.projectPath(owner, repo)
+ url := fmt.Sprintf("%s/api/v4/projects/%s/issues", g.BaseURL, pp)
+ payload := map[string]interface{}{
+ "title": issue.Title,
+ "description": issue.Body,
+ "labels": strings.Join(issue.Labels, ","),
+ }
+ if issue.MilestoneTitle != "" {
+ if msID, ok := milestoneMap[issue.MilestoneTitle]; ok {
+ payload["milestone_id"] = msID
+ }
+ }
+ resp, err := g.doRequest("POST", url, payload)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ // 如果需要关闭 issue
+ if issue.State == "closed" {
+ var created struct {
+ IID int `json:"iid"`
+ }
+ json.NewDecoder(resp.Body).Decode(&created)
+ closeURL := fmt.Sprintf("%s/api/v4/projects/%s/issues/%d", g.BaseURL, pp, created.IID)
+ closeResp, err := g.doRequest("PUT", closeURL, map[string]string{"state_event": "close"})
+ if err == nil {
+ closeResp.Body.Close()
+ }
+ }
+ return nil
+}
+
+func (g *GitLabAPI) ListPullRequests(owner, repo string) ([]PullRequestInfo, error) {
+ var all []PullRequestInfo
+ pp := g.projectPath(owner, repo)
+ for _, state := range []string{"opened", "closed", "merged"} {
+ page := 1
+ for {
+ url := fmt.Sprintf("%s/api/v4/projects/%s/merge_requests?state=%s&per_page=100&page=%d&order_by=created_at&sort=asc", g.BaseURL, pp, state, page)
+ resp, err := g.doRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ var items []struct {
+ ID int64 `json:"id"`
+ IID int `json:"iid"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ State string `json:"state"`
+ SourceBranch string `json:"source_branch"`
+ TargetBranch string `json:"target_branch"`
+ Author struct {
+ Username string `json:"username"`
+ } `json:"author"`
+ CreatedAt string `json:"created_at"`
+ }
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ json.NewDecoder(resp.Body).Decode(&items)
+ resp.Body.Close()
+ if len(items) == 0 {
+ break
+ }
+ for _, it := range items {
+ st := it.State
+ if st == "opened" {
+ st = "open"
+ }
+ all = append(all, PullRequestInfo{
+ ID: it.ID, Number: it.IID, Title: it.Title, Body: it.Description,
+ State: st, Head: it.SourceBranch, Base: it.TargetBranch,
+ User: it.Author.Username, CreatedAt: it.CreatedAt,
+ })
+ }
+ if len(items) < 100 {
+ break
+ }
+ page++
+ }
+ }
+ return all, nil
+}
+
+// ================ Gitee ================
+
+func (g *GiteeAPI) ListLabels(owner, repo string) ([]LabelInfo, error) {
+ var all []LabelInfo
+ page := 1
+ for {
+ url := fmt.Sprintf("https://gitee.com/api/v5/repos/%s/%s/labels?access_token=%s&per_page=100&page=%d", owner, repo, g.Token, page)
+ resp, err := g.doRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ var items []struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Color string `json:"color"`
+ }
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ json.NewDecoder(resp.Body).Decode(&items)
+ resp.Body.Close()
+ if len(items) == 0 {
+ break
+ }
+ for _, it := range items {
+ all = append(all, LabelInfo{ID: it.ID, Name: it.Name, Color: strings.TrimPrefix(it.Color, "#")})
+ }
+ if len(items) < 100 {
+ break
+ }
+ page++
+ }
+ return all, nil
+}
+
+func (g *GiteeAPI) CreateLabel(owner, repo string, label LabelInfo) error {
+ url := fmt.Sprintf("https://gitee.com/api/v5/repos/%s/%s/labels", owner, repo)
+ color := label.Color
+ if !strings.HasPrefix(color, "#") {
+ color = "#" + color
+ }
+ payload := map[string]interface{}{
+ "access_token": g.Token,
+ "name": label.Name,
+ "color": color,
+ }
+ resp, err := g.doRequest("POST", url, payload)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ return nil
+}
+
+func (g *GiteeAPI) ListMilestones(owner, repo string) ([]MilestoneInfo, error) {
+ var all []MilestoneInfo
+ for _, state := range []string{"open", "closed"} {
+ page := 1
+ for {
+ url := fmt.Sprintf("https://gitee.com/api/v5/repos/%s/%s/milestones?access_token=%s&state=%s&per_page=100&page=%d", owner, repo, g.Token, state, page)
+ resp, err := g.doRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ var items []struct {
+ ID int64 `json:"id"`
+ Number int `json:"number"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ State string `json:"state"`
+ DueOn string `json:"due_on"`
+ }
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ json.NewDecoder(resp.Body).Decode(&items)
+ resp.Body.Close()
+ if len(items) == 0 {
+ break
+ }
+ for _, it := range items {
+ all = append(all, MilestoneInfo{
+ ID: it.ID, Title: it.Title, Description: it.Description, State: it.State, DueOn: it.DueOn,
+ })
+ }
+ if len(items) < 100 {
+ break
+ }
+ page++
+ }
+ }
+ return all, nil
+}
+
+func (g *GiteeAPI) CreateMilestone(owner, repo string, ms MilestoneInfo) (*MilestoneInfo, error) {
+ url := fmt.Sprintf("https://gitee.com/api/v5/repos/%s/%s/milestones", owner, repo)
+ payload := map[string]interface{}{
+ "access_token": g.Token,
+ "title": ms.Title,
+ "description": ms.Description,
+ "state": ms.State,
+ }
+ if ms.DueOn != "" {
+ payload["due_on"] = ms.DueOn
+ }
+ resp, err := g.doRequest("POST", url, payload)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ var result struct {
+ ID int64 `json:"id"`
+ Title string `json:"title"`
+ }
+ json.NewDecoder(resp.Body).Decode(&result)
+ return &MilestoneInfo{ID: result.ID, Title: result.Title, State: ms.State}, nil
+}
+
+func (g *GiteeAPI) ListIssues(owner, repo string) ([]IssueInfo, error) {
+ var all []IssueInfo
+ for _, state := range []string{"open", "closed"} {
+ page := 1
+ for {
+ url := fmt.Sprintf("https://gitee.com/api/v5/repos/%s/%s/issues?access_token=%s&state=%s&per_page=100&page=%d&direction=asc", owner, repo, g.Token, state, page)
+ resp, err := g.doRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ var items []struct {
+ ID int64 `json:"id"`
+ Number string `json:"number"` // Gitee uses string
+ Title string `json:"title"`
+ Body string `json:"body"`
+ State string `json:"state"`
+ Labels []struct {
+ Name string `json:"name"`
+ } `json:"labels"`
+ Milestone *struct {
+ Title string `json:"title"`
+ } `json:"milestone"`
+ CreatedAt string `json:"created_at"`
+ }
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ json.NewDecoder(resp.Body).Decode(&items)
+ resp.Body.Close()
+ if len(items) == 0 {
+ break
+ }
+ for _, it := range items {
+ issue := IssueInfo{
+ ID: it.ID, Title: it.Title, Body: it.Body,
+ State: it.State, CreatedAt: it.CreatedAt,
+ }
+ for _, l := range it.Labels {
+ issue.Labels = append(issue.Labels, l.Name)
+ }
+ if it.Milestone != nil {
+ issue.MilestoneTitle = it.Milestone.Title
+ }
+ all = append(all, issue)
+ }
+ if len(items) < 100 {
+ break
+ }
+ page++
+ }
+ }
+ return all, nil
+}
+
+func (g *GiteeAPI) CreateIssue(owner, repo string, issue IssueInfo, milestoneMap map[string]int64) error {
+ url := fmt.Sprintf("https://gitee.com/api/v5/repos/%s/issues", owner)
+ payload := map[string]interface{}{
+ "access_token": g.Token,
+ "repo": repo,
+ "title": issue.Title,
+ "body": issue.Body,
+ }
+ if len(issue.Labels) > 0 {
+ payload["labels"] = strings.Join(issue.Labels, ",")
+ }
+ if issue.MilestoneTitle != "" {
+ if msID, ok := milestoneMap[issue.MilestoneTitle]; ok {
+ payload["milestone"] = msID
+ }
+ }
+ resp, err := g.doRequest("POST", url, payload)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ // Gitee 关闭 issue 需要 PATCH
+ if issue.State == "closed" {
+ var created struct {
+ Number string `json:"number"`
+ }
+ json.NewDecoder(resp.Body).Decode(&created)
+ closeURL := fmt.Sprintf("https://gitee.com/api/v5/repos/%s/%s/issues/%s", owner, repo, created.Number)
+ closeResp, err := g.doRequest("PATCH", closeURL, map[string]interface{}{
+ "access_token": g.Token,
+ "state": "closed",
+ })
+ if err == nil {
+ closeResp.Body.Close()
+ }
+ }
+ return nil
+}
+
+func (g *GiteeAPI) ListPullRequests(owner, repo string) ([]PullRequestInfo, error) {
+ var all []PullRequestInfo
+ for _, state := range []string{"open", "closed", "merged"} {
+ page := 1
+ for {
+ url := fmt.Sprintf("https://gitee.com/api/v5/repos/%s/%s/pulls?access_token=%s&state=%s&per_page=100&page=%d", owner, repo, g.Token, state, page)
+ resp, err := g.doRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ var items []struct {
+ ID int64 `json:"id"`
+ Number int `json:"number"`
+ Title string `json:"title"`
+ Body string `json:"body"`
+ State string `json:"state"`
+ Head struct {
+ Ref string `json:"ref"`
+ } `json:"head"`
+ Base struct {
+ Ref string `json:"ref"`
+ } `json:"base"`
+ User struct {
+ Login string `json:"login"`
+ } `json:"user"`
+ CreatedAt string `json:"created_at"`
+ }
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
+ }
+ json.NewDecoder(resp.Body).Decode(&items)
+ resp.Body.Close()
+ if len(items) == 0 {
+ break
+ }
+ for _, it := range items {
+ all = append(all, PullRequestInfo{
+ ID: it.ID, Number: it.Number, Title: it.Title, Body: it.Body,
+ State: it.State, Head: it.Head.Ref, Base: it.Base.Ref,
+ User: it.User.Login, CreatedAt: it.CreatedAt,
+ })
+ }
+ if len(items) < 100 {
+ break
+ }
+ page++
+ }
+ }
+ return all, nil
+}
diff --git a/internal/git/client.go b/internal/git/client.go
index 805f95b..e7cc64e 100644
--- a/internal/git/client.go
+++ b/internal/git/client.go
@@ -62,6 +62,35 @@ func (g *GitClient) RunWithProxy(path string, useProxy *bool, args ...string) (s
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) {
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) {
+ return g.CloneWithProxy(repoURL, path, nil)
+}
+
+// CloneWithProxy 克隆仓库,useProxy 可独立控制代理
+func (g *GitClient) CloneWithProxy(repoURL, path string, useProxy *bool) (string, error) {
// Clone 操作可能需要较长时间,使用独立的超时设置(10 分钟)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
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, "clone", "--progress", repoURL, path)