GitHub improvements
This commit is contained in:
@@ -9,8 +9,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/nexus/internal/models"
|
||||
@@ -42,6 +45,7 @@ type apiRepo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
Private bool `json:"private"`
|
||||
Fork bool `json:"fork"`
|
||||
Archived bool `json:"archived"`
|
||||
@@ -58,6 +62,15 @@ type apiRepo struct {
|
||||
// paginating through the API. Results are sorted by pushed_at descending
|
||||
// (most recently active first).
|
||||
func (c *Client) FetchRepos(ctx context.Context) ([]models.GitHubRepo, error) {
|
||||
return c.fetchRepos(ctx, nil)
|
||||
}
|
||||
|
||||
// FetchReposWithProgress fetches repositories and reports enrichment progress.
|
||||
func (c *Client) FetchReposWithProgress(ctx context.Context, progress func(done int, total int)) ([]models.GitHubRepo, error) {
|
||||
return c.fetchRepos(ctx, progress)
|
||||
}
|
||||
|
||||
func (c *Client) fetchRepos(ctx context.Context, progress func(done int, total int)) ([]models.GitHubRepo, error) {
|
||||
var all []apiRepo
|
||||
|
||||
page := 1
|
||||
@@ -120,6 +133,7 @@ func (c *Client) FetchRepos(ctx context.Context) ([]models.GitHubRepo, error) {
|
||||
Owner: r.Owner.Login,
|
||||
Description: r.Description,
|
||||
HTMLURL: r.HTMLURL,
|
||||
DefaultBranch: r.DefaultBranch,
|
||||
Private: r.Private,
|
||||
Fork: r.Fork,
|
||||
Archived: r.Archived,
|
||||
@@ -129,9 +143,149 @@ func (c *Client) FetchRepos(ctx context.Context) ([]models.GitHubRepo, error) {
|
||||
UpdatedAt: r.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
if progress != nil {
|
||||
progress(0, len(result))
|
||||
}
|
||||
|
||||
// Enrich with latest commit metadata (best-effort).
|
||||
sem := make(chan struct{}, 5)
|
||||
var wg sync.WaitGroup
|
||||
var done int32
|
||||
for i := range result {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
ref := result[idx].DefaultBranch
|
||||
if ref == "" {
|
||||
ref = "HEAD"
|
||||
}
|
||||
committer, committedAt, err := c.FetchLatestCommit(ctx, result[idx].FullName, ref)
|
||||
if err != nil {
|
||||
if progress != nil {
|
||||
current := int(atomic.AddInt32(&done, 1))
|
||||
progress(current, len(result))
|
||||
}
|
||||
return
|
||||
}
|
||||
result[idx].LatestCommitter = committer
|
||||
result[idx].LatestCommitAt = committedAt
|
||||
if progress != nil {
|
||||
current := int(atomic.AddInt32(&done, 1))
|
||||
progress(current, len(result))
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// FetchLatestCommit returns the committer name and commit time for a repo/ref.
|
||||
func (c *Client) FetchLatestCommit(ctx context.Context, fullName string, ref string) (string, string, error) {
|
||||
url := fmt.Sprintf("%s/repos/%s/commits/%s", c.baseURL, fullName, ref)
|
||||
if ref == "" {
|
||||
url = fmt.Sprintf("%s/repos/%s/commits/HEAD", c.baseURL, fullName)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("building request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "NexusTerminal/1.0")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("fetching commit: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return "", "", fmt.Errorf("authentication failed (HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", "", fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Commit struct {
|
||||
Committer struct {
|
||||
Name string `json:"name"`
|
||||
Date time.Time `json:"date"`
|
||||
} `json:"committer"`
|
||||
Author struct {
|
||||
Name string `json:"name"`
|
||||
Date time.Time `json:"date"`
|
||||
} `json:"author"`
|
||||
} `json:"commit"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return "", "", fmt.Errorf("decoding commit: %w", err)
|
||||
}
|
||||
|
||||
name := payload.Commit.Committer.Name
|
||||
date := payload.Commit.Committer.Date
|
||||
if name == "" {
|
||||
name = payload.Commit.Author.Name
|
||||
date = payload.Commit.Author.Date
|
||||
}
|
||||
if name == "" || date.IsZero() {
|
||||
return "", "", fmt.Errorf("commit metadata missing")
|
||||
}
|
||||
|
||||
return name, date.Format(time.RFC3339), nil
|
||||
}
|
||||
|
||||
// DownloadRepoArchive downloads a repository zip archive to the given path.
|
||||
func (c *Client) DownloadRepoArchive(ctx context.Context, fullName string, ref string, destPath string) error {
|
||||
if fullName == "" {
|
||||
return fmt.Errorf("repository name cannot be empty")
|
||||
}
|
||||
url := fmt.Sprintf("%s/repos/%s/zipball", c.baseURL, fullName)
|
||||
if ref != "" {
|
||||
url = url + "/" + ref
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "NexusTerminal/1.0")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("downloading archive: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return fmt.Errorf("authentication failed (HTTP %d) — check your access token", resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
file, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := io.Copy(file, resp.Body); err != nil {
|
||||
return fmt.Errorf("writing archive: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateToken makes a lightweight call to /user to confirm the token works.
|
||||
func (c *Client) ValidateToken(ctx context.Context) (string, error) {
|
||||
url := c.baseURL + "/user"
|
||||
|
||||
Reference in New Issue
Block a user