Files
kmanage/pkg/git/client.go
T

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
}