GitHub improvements

This commit is contained in:
Andy Dixon
2026-04-20 13:40:00 +01:00
parent 1d97e907e0
commit c5debd9c77
20 changed files with 2525 additions and 1238 deletions
+41
View File
@@ -10,6 +10,7 @@ import (
"net"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
@@ -748,6 +749,46 @@ func (s *Service) RefreshGitHubRepos(sessionID string) ([]models.GitHubRepo, err
return s.sessions.FetchGitHubRepos(sessionID)
}
// DownloadGitHubRepoArchive downloads a repository zip archive to a local file.
func (s *Service) DownloadGitHubRepoArchive(sessionID string, fullName string, ref string) (string, error) {
if s.ctx == nil {
return "", fmt.Errorf("application context not set")
}
if fullName == "" {
return "", fmt.Errorf("repository name cannot be empty")
}
parts := strings.Split(fullName, "/")
repoName := parts[len(parts)-1]
defaultName := repoName + ".zip"
if ref != "" {
safeRef := strings.ReplaceAll(ref, "/", "-")
defaultName = fmt.Sprintf("%s-%s.zip", repoName, safeRef)
}
path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{
Title: "Download Repository",
DefaultFilename: defaultName,
Filters: []wailsRuntime.FileFilter{
{DisplayName: "Zip Archive (*.zip)", Pattern: "*.zip"},
},
})
if err != nil {
return "", fmt.Errorf("save dialog failed: %w", err)
}
if path == "" {
return "", fmt.Errorf("download cancelled")
}
if filepath.Ext(path) != ".zip" {
path += ".zip"
}
if err := s.sessions.DownloadGitHubArchive(sessionID, fullName, ref, path); err != nil {
return "", err
}
return path, nil
}
// ---------------------------------------------------------------------------
// Port forwarding
// ---------------------------------------------------------------------------
+154
View File
@@ -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"
+3
View File
@@ -199,6 +199,9 @@ type GitHubRepo struct {
Owner string `json:"owner"`
Description string `json:"description"`
HTMLURL string `json:"htmlURL"`
DefaultBranch string `json:"defaultBranch"`
LatestCommitter string `json:"latestCommitter"`
LatestCommitAt string `json:"latestCommitAt"`
Private bool `json:"private"`
Fork bool `json:"fork"`
Archived bool `json:"archived"`
+106 -3
View File
@@ -703,13 +703,40 @@ func (m *Manager) connectGitHub(sess *Session, getSecret func(prompt string) (st
// Validate the token.
login, err := client.ValidateToken(context.Background())
if err != nil {
return fmt.Errorf("GitHub authentication failed: %w", err)
if m.isGitHubAuthError(err) && getSecret != nil {
retryToken, retryErr := m.promptAndStoreSecret(
profile.ID,
"github-token",
ghConf.SecretMode,
getSecret,
"GitHub token did not work. Please enter a valid token:",
)
if retryErr == nil && retryToken != "" {
client = githubclient.New(ghConf.BaseURL, retryToken)
login, err = client.ValidateToken(context.Background())
}
}
if err != nil {
return fmt.Errorf("GitHub authentication failed: %w", err)
}
}
sess.githubClient = client
// Perform the initial fetch.
repos, err := client.FetchRepos(context.Background())
m.emitEvent("session:github:status", map[string]interface{}{
"sessionID": sess.Info.ID,
"status": "Loading repositories...",
})
repos, err := client.FetchReposWithProgress(context.Background(), func(done int, total int) {
if total <= 0 {
return
}
m.emitEvent("session:github:status", map[string]interface{}{
"sessionID": sess.Info.ID,
"status": fmt.Sprintf("Loading commit info (%d/%d)", done, total),
})
})
if err != nil {
return fmt.Errorf("fetching repositories: %w", err)
}
@@ -739,7 +766,19 @@ func (m *Manager) connectGitHub(sess *Session, getSecret func(prompt string) (st
case <-ctx.Done():
return
case <-ticker.C:
updated, fetchErr := client.FetchRepos(ctx)
m.emitEvent("session:github:status", map[string]interface{}{
"sessionID": sess.Info.ID,
"status": "Refreshing repositories...",
})
updated, fetchErr := client.FetchReposWithProgress(ctx, func(done int, total int) {
if total <= 0 {
return
}
m.emitEvent("session:github:status", map[string]interface{}{
"sessionID": sess.Info.ID,
"status": fmt.Sprintf("Loading commit info (%d/%d)", done, total),
})
})
if fetchErr != nil {
m.emitEvent("session:github:error", map[string]interface{}{
"sessionID": sess.Info.ID,
@@ -785,6 +824,29 @@ func (m *Manager) FetchGitHubRepos(sessionID string) ([]models.GitHubRepo, error
return repos, nil
}
// DownloadGitHubArchive downloads a repository zip archive for a GitHub session.
func (m *Manager) DownloadGitHubArchive(sessionID string, fullName string, ref string, destPath string) error {
m.mu.RLock()
sess, ok := m.sessions[sessionID]
m.mu.RUnlock()
if !ok {
return fmt.Errorf("sessions: session %q not found", sessionID)
}
if sess.githubClient == nil {
return fmt.Errorf("sessions: session %q is not a GitHub connection", sessionID)
}
if ref == "" {
ref = "HEAD"
}
if err := sess.githubClient.DownloadRepoArchive(context.Background(), fullName, ref, destPath); err != nil {
return fmt.Errorf("downloading archive: %w", err)
}
return nil
}
// ---------------------------------------------------------------------------
// Session recording
// ---------------------------------------------------------------------------
@@ -1179,6 +1241,47 @@ func (m *Manager) buildSecretCallback(profileID string, secretType string, mode
}
}
// promptAndStoreSecret asks the user for a secret and stores it based on the mode.
func (m *Manager) promptAndStoreSecret(profileID string, secretType string, mode models.SecretMode, getSecret func(prompt string) (string, error), prompt string) (string, error) {
if getSecret == nil {
return "", fmt.Errorf("no %s available and no prompt callback provided", secretType)
}
val, err := getSecret(prompt)
if err != nil {
return "", err
}
if val == "" {
return "", fmt.Errorf("%s cannot be empty", secretType)
}
if mode == models.SecretModeSaved {
stored := false
secretKey := fmt.Sprintf("profile:%s:%s", profileID, secretType)
if m.keychainStore != nil {
if sErr := m.keychainStore.SetSecret(secretKey, val); sErr == nil {
stored = true
}
}
if !stored && m.secretStore != nil && m.secretStore.IsUnlocked() {
_ = m.secretStore.SetSecret(secretKey, val)
}
}
return val, nil
}
// isGitHubAuthError checks whether an error indicates authentication failure.
func (m *Manager) isGitHubAuthError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "token validation failed") ||
strings.Contains(msg, "authentication failed") ||
strings.Contains(msg, "http 401") ||
strings.Contains(msg, "http 403")
}
// closeSession shuts down the protocol client for a session.
func (m *Manager) closeSession(sess *Session) {
// Cancel any in-progress reconnect backoff.