Some checks failed
Release / build-windows (push) Has been cancelled
✨ 新功能: - GitLab 平台完整支持(16+7 个 API 方法、前端图标/选择器) - Release 同步支持手动输入 Token 🐛 Bug 修复: - 自建平台凭证匹配(BaseURL 优先精确匹配) - Gitea 迁移 API service 字段类型修复(HTTP 422) - 构建时不再覆盖已有 config.yaml(数据丢失问题) - 错误信息显示实际 URL 🔨 改进: - 源平台开启代理时自动跳过 Gitea 原生迁移,改用本地代理中转 - Push 超时从 30s 提升到 10 分钟 - 设置 http.postBuffer=500MB,push --all 失败自动逐分支推送 - 新增 RunWithProxyTimeout 方法
1127 lines
32 KiB
Go
1127 lines
32 KiB
Go
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)
|
||
}
|