Files
GitPilot/internal/app/platform.go
zyj 3ebcd10a23
Some checks failed
Release / build-windows (push) Has been cancelled
release: v0.4.1 - GitLab支持、迁移优化、Bug修复
 新功能:
- GitLab 平台完整支持(16+7 个 API 方法、前端图标/选择器)
- Release 同步支持手动输入 Token

🐛 Bug 修复:
- 自建平台凭证匹配(BaseURL 优先精确匹配)
- Gitea 迁移 API service 字段类型修复(HTTP 422)
- 构建时不再覆盖已有 config.yaml(数据丢失问题)
- 错误信息显示实际 URL

🔨 改进:
- 源平台开启代理时自动跳过 Gitea 原生迁移,改用本地代理中转
- Push 超时从 30s 提升到 10 分钟
- 设置 http.postBuffer=500MB,push --all 失败自动逐分支推送
- 新增 RunWithProxyTimeout 方法
2026-03-12 15:07:41 +08:00

1127 lines
32 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}