424 lines
12 KiB
Go
424 lines
12 KiB
Go
package git
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ProviderType represents the type of git provider.
|
|
type ProviderType string
|
|
|
|
const (
|
|
ProviderGitea ProviderType = "gitea"
|
|
ProviderForgejo ProviderType = "forgejo"
|
|
ProviderGitHub ProviderType = "github"
|
|
)
|
|
|
|
// Repository represents a git repository compatible across Gitea, Forgejo, and GitHub APIs.
|
|
type Repository struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
FullName string `json:"full_name"`
|
|
Description string `json:"description"`
|
|
Private bool `json:"private"`
|
|
Fork bool `json:"fork"`
|
|
HTMLURL string `json:"html_url"`
|
|
SSHURL string `json:"ssh_url"`
|
|
CloneURL string `json:"clone_url"`
|
|
DefaultBranch string `json:"default_branch"`
|
|
DefaultDeleteBranchAfterMerge bool `json:"default_delete_branch_after_merge"`
|
|
DeleteBranchOnMerge bool `json:"delete_branch_on_merge"`
|
|
StarsCount int `json:"stargazers_count"`
|
|
ForksCount int `json:"forks_count"`
|
|
OpenIssues int `json:"open_issues_count"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
Owner User `json:"owner"`
|
|
AccountName string `json:"-"`
|
|
}
|
|
|
|
// IsAutoDeleteBranch returns true if automatic branch deletion after pull request merge is enabled.
|
|
func (r *Repository) IsAutoDeleteBranch() bool {
|
|
return r.DefaultDeleteBranchAfterMerge || r.DeleteBranchOnMerge
|
|
}
|
|
|
|
// User represents a repository owner or user account.
|
|
type User struct {
|
|
ID int64 `json:"id"`
|
|
UserName string `json:"login"`
|
|
}
|
|
|
|
// Hook represents a configured webhook.
|
|
type Hook struct {
|
|
ID int64 `json:"id"`
|
|
Type string `json:"type"`
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
Config map[string]string `json:"config"`
|
|
Events []string `json:"events"`
|
|
Active bool `json:"active"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// TargetURL returns the webhook target endpoint URL.
|
|
func (h *Hook) TargetURL() string {
|
|
if h.Config != nil {
|
|
if u, ok := h.Config["url"]; ok && u != "" {
|
|
return u
|
|
}
|
|
}
|
|
return h.URL
|
|
}
|
|
|
|
// RepositoryWithHooks aggregates a repository along with its configured webhooks.
|
|
type RepositoryWithHooks struct {
|
|
Repository
|
|
Hooks []Hook
|
|
}
|
|
|
|
// Client interacts with Git providers (Gitea, Forgejo, GitHub).
|
|
type Client struct {
|
|
AccountName string
|
|
BaseURL string
|
|
Token string
|
|
ProviderType ProviderType
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewClient creates a new Git client instance.
|
|
func NewClient(accountName, baseURL, token string, providerType ProviderType) *Client {
|
|
cleanURL := strings.TrimRight(baseURL, "/")
|
|
if cleanURL == "" {
|
|
if providerType == ProviderGitHub {
|
|
cleanURL = "https://api.github.com"
|
|
}
|
|
}
|
|
if providerType == "" {
|
|
if strings.Contains(cleanURL, "github.com") {
|
|
providerType = ProviderGitHub
|
|
} else {
|
|
providerType = ProviderGitea
|
|
}
|
|
}
|
|
|
|
return &Client{
|
|
AccountName: accountName,
|
|
BaseURL: cleanURL,
|
|
Token: token,
|
|
ProviderType: providerType,
|
|
httpClient: &http.Client{
|
|
Timeout: 15 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c *Client) setAuthHeaders(req *http.Request) {
|
|
if c.Token != "" {
|
|
if c.ProviderType == ProviderGitHub {
|
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
|
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
|
} else {
|
|
req.Header.Set("Authorization", "token "+c.Token)
|
|
}
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
}
|
|
|
|
// ListRepositories retrieves all repositories accessible by the configured account.
|
|
func (c *Client) ListRepositories() ([]Repository, error) {
|
|
var allRepos []Repository
|
|
page := 1
|
|
limit := 100
|
|
|
|
for {
|
|
var url string
|
|
if c.ProviderType == ProviderGitHub {
|
|
url = fmt.Sprintf("%s/user/repos?per_page=%d&page=%d&affiliation=owner,collaborator,organization_member", c.BaseURL, limit, page)
|
|
} else {
|
|
url = fmt.Sprintf("%s/api/v1/user/repos?limit=%d&page=%d", c.BaseURL, limit, page)
|
|
}
|
|
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create http request: %w", err)
|
|
}
|
|
|
|
c.setAuthHeaders(req)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to send request: %w", err)
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("git api error (status %s): %s", resp.Status, string(body))
|
|
}
|
|
|
|
var pageRepos []Repository
|
|
if err := json.Unmarshal(body, &pageRepos); err != nil {
|
|
return nil, fmt.Errorf("failed to parse json response (%w): %s", err, string(body))
|
|
}
|
|
|
|
if len(pageRepos) == 0 {
|
|
break
|
|
}
|
|
|
|
for i := range pageRepos {
|
|
pageRepos[i].AccountName = c.AccountName
|
|
}
|
|
|
|
allRepos = append(allRepos, pageRepos...)
|
|
|
|
if len(pageRepos) < limit {
|
|
break
|
|
}
|
|
page++
|
|
}
|
|
|
|
return allRepos, nil
|
|
}
|
|
|
|
// ListHooks retrieves all configured webhooks for a repository.
|
|
func (c *Client) ListHooks(owner, repo string) ([]Hook, error) {
|
|
var url string
|
|
if c.ProviderType == ProviderGitHub {
|
|
url = fmt.Sprintf("%s/repos/%s/%s/hooks", c.BaseURL, owner, repo)
|
|
} else {
|
|
url = fmt.Sprintf("%s/api/v1/repos/%s/%s/hooks", c.BaseURL, owner, repo)
|
|
}
|
|
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create webhook request: %w", err)
|
|
}
|
|
|
|
c.setAuthHeaders(req)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to retrieve webhooks: %w", err)
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read webhook response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("webhook api error (status %s): %s", resp.Status, string(body))
|
|
}
|
|
|
|
var hooks []Hook
|
|
if err := json.Unmarshal(body, &hooks); err != nil {
|
|
return nil, fmt.Errorf("failed to parse webhook json response: %w", err)
|
|
}
|
|
|
|
return hooks, nil
|
|
}
|
|
|
|
// ListRepositoriesWithHooks retrieves all repositories and concurrently fetches their webhooks.
|
|
func (c *Client) ListRepositoriesWithHooks() ([]RepositoryWithHooks, error) {
|
|
repos, err := c.ListRepositories()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
results := make([]RepositoryWithHooks, len(repos))
|
|
|
|
var wg sync.WaitGroup
|
|
semaphore := make(chan struct{}, 10)
|
|
|
|
for i, r := range repos {
|
|
wg.Add(1)
|
|
go func(idx int, repo Repository) {
|
|
defer wg.Done()
|
|
semaphore <- struct{}{}
|
|
defer func() { <-semaphore }()
|
|
|
|
ownerName := repo.Owner.UserName
|
|
if ownerName == "" && strings.Contains(repo.FullName, "/") {
|
|
ownerName = strings.SplitN(repo.FullName, "/", 2)[0]
|
|
}
|
|
|
|
repoName := repo.Name
|
|
if repoName == "" && strings.Contains(repo.FullName, "/") {
|
|
repoName = strings.SplitN(repo.FullName, "/", 2)[1]
|
|
}
|
|
|
|
hooks, err := c.ListHooks(ownerName, repoName)
|
|
if err != nil {
|
|
hooks = []Hook{}
|
|
}
|
|
|
|
results[idx] = RepositoryWithHooks{
|
|
Repository: repo,
|
|
Hooks: hooks,
|
|
}
|
|
}(i, r)
|
|
}
|
|
|
|
wg.Wait()
|
|
return results, nil
|
|
}
|
|
|
|
// DeleteHook deletes a single webhook by ID.
|
|
func (c *Client) DeleteHook(owner, repo string, hookID int64) error {
|
|
var url string
|
|
if c.ProviderType == ProviderGitHub {
|
|
url = fmt.Sprintf("%s/repos/%s/%s/hooks/%d", c.BaseURL, owner, repo, hookID)
|
|
} else {
|
|
url = fmt.Sprintf("%s/api/v1/repos/%s/%s/hooks/%d", c.BaseURL, owner, repo, hookID)
|
|
}
|
|
|
|
req, err := http.NewRequest("DELETE", url, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create delete request: %w", err)
|
|
}
|
|
|
|
c.setAuthHeaders(req)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to execute delete request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("delete failed (status %s): %s", resp.Status, string(body))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetAutoDeleteBranch enables or disables automatic pull request branch deletion on merge.
|
|
func (c *Client) SetAutoDeleteBranch(owner, repo string, enable bool) (*Repository, error) {
|
|
var url string
|
|
var payload map[string]interface{}
|
|
|
|
if c.ProviderType == ProviderGitHub {
|
|
url = fmt.Sprintf("%s/repos/%s/%s", c.BaseURL, owner, repo)
|
|
payload = map[string]interface{}{
|
|
"delete_branch_on_merge": enable,
|
|
}
|
|
} else {
|
|
url = fmt.Sprintf("%s/api/v1/repos/%s/%s", c.BaseURL, owner, repo)
|
|
payload = map[string]interface{}{
|
|
"default_delete_branch_after_merge": enable,
|
|
}
|
|
}
|
|
|
|
bodyBytes, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to serialize options: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequest("PATCH", url, bytes.NewBuffer(bodyBytes))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create patch request: %w", err)
|
|
}
|
|
|
|
c.setAuthHeaders(req)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to execute patch request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("repository update failed (status %s): %s", resp.Status, string(body))
|
|
}
|
|
|
|
var updatedRepo Repository
|
|
if err := json.Unmarshal(body, &updatedRepo); err != nil {
|
|
return nil, fmt.Errorf("failed to parse response: %w", err)
|
|
}
|
|
updatedRepo.AccountName = c.AccountName
|
|
|
|
return &updatedRepo, nil
|
|
}
|
|
|
|
// CreateRepoOptions defines parameters for creating a new repository.
|
|
type CreateRepoOptions struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Private bool `json:"private"`
|
|
AutoInit bool `json:"auto_init"`
|
|
DefaultBranch string `json:"default_branch,omitempty"`
|
|
Org string `json:"-"`
|
|
}
|
|
|
|
// CreateRepository creates a new repository on the Git provider under the user or an organization.
|
|
func (c *Client) CreateRepository(opt CreateRepoOptions) (*Repository, error) {
|
|
var url string
|
|
if c.ProviderType == ProviderGitHub {
|
|
if opt.Org != "" {
|
|
url = fmt.Sprintf("%s/orgs/%s/repos", c.BaseURL, opt.Org)
|
|
} else {
|
|
url = fmt.Sprintf("%s/user/repos", c.BaseURL)
|
|
}
|
|
} else {
|
|
if opt.Org != "" {
|
|
url = fmt.Sprintf("%s/api/v1/orgs/%s/repos", c.BaseURL, opt.Org)
|
|
} else {
|
|
url = fmt.Sprintf("%s/api/v1/user/repos", c.BaseURL)
|
|
}
|
|
}
|
|
|
|
bodyBytes, err := json.Marshal(opt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to serialize create repository payload: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", url, bytes.NewBuffer(bodyBytes))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create http request: %w", err)
|
|
}
|
|
|
|
c.setAuthHeaders(req)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to execute create repository request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("repository creation failed (status %s): %s", resp.Status, string(body))
|
|
}
|
|
|
|
var createdRepo Repository
|
|
if err := json.Unmarshal(body, &createdRepo); err != nil {
|
|
return nil, fmt.Errorf("failed to parse created repository response: %w", err)
|
|
}
|
|
createdRepo.AccountName = c.AccountName
|
|
|
|
return &createdRepo, nil
|
|
}
|