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(() => { + + + @@ -2050,6 +2201,42 @@ const currentRemoteUrl = computed(() => { 共 {{ tags.length }} 个标签 + + @@ -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 @@ @@ -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)