Add GitHub connection type — repo viewer with org grouping and auto-refresh

New protocol type 'github' for browsing repositories via the GitHub REST API.
Creates a non-terminal panel showing repos grouped by owner/org, sorted by
last commit date (newest first), with search filtering and auto-refresh.

Backend:
- ProtocolGitHub constant and GitHubConfig/GitHubRepo model structs
- New internal/githubclient package (REST API, pagination, token validation)
- Session manager connectGitHub() with periodic refresh goroutine
- API surface: RefreshGitHubRepos() for on-demand refresh
- Token stored via existing credential infrastructure (keychain/vault)

Frontend:
- GitHub tab in profile editor with API URL and token storage fields
- New github.js viewer component (grouped repos, badges, language dots)
- github.css with charcoal-themed repo cards and org headers
- Protocol colour tokens, tab badge, sidebar indicator for GitHub
- Event wiring for session:github:repos and session:github:error
- Cleanup on tab close

Supports GitHub Enterprise via configurable API base URL.
This commit is contained in:
2026-04-16 00:00:00 +01:00
parent 58eb1626f6
commit c98ace2396
20 changed files with 1179 additions and 21 deletions
+163
View File
@@ -0,0 +1,163 @@
// Package githubclient provides a lightweight GitHub REST API client for
// fetching repository metadata. It supports both github.com and GitHub
// Enterprise instances via a configurable base URL.
package githubclient
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
"github.com/copilot-ssh-client/app/internal/models"
)
// Client wraps the GitHub REST API for repository listing.
type Client struct {
baseURL string
token string
httpClient *http.Client
}
// New creates a Client for the given API base URL and personal access token.
// baseURL should be the API root, e.g. "https://api.github.com" or
// "https://ghe.example.com/api/v3".
func New(baseURL, token string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// apiRepo is the subset of the GitHub API response we care about.
type apiRepo struct {
FullName string `json:"full_name"`
Name string `json:"name"`
Description string `json:"description"`
HTMLURL string `json:"html_url"`
Private bool `json:"private"`
Fork bool `json:"fork"`
Archived bool `json:"archived"`
Language string `json:"language"`
Stars int `json:"stargazers_count"`
PushedAt time.Time `json:"pushed_at"`
UpdatedAt time.Time `json:"updated_at"`
Owner struct {
Login string `json:"login"`
} `json:"owner"`
}
// FetchRepos retrieves all repositories visible to the authenticated user,
// 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) {
var all []apiRepo
page := 1
for {
url := fmt.Sprintf("%s/user/repos?per_page=100&page=%d&sort=pushed&direction=desc", c.baseURL, page)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, 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 nil, fmt.Errorf("fetching repos page %d: %w", page, err)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("reading response body: %w", readErr)
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return nil, fmt.Errorf("authentication failed (HTTP %d) — check your access token", resp.StatusCode)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, string(body))
}
var batch []apiRepo
if err := json.Unmarshal(body, &batch); err != nil {
return nil, fmt.Errorf("decoding repos: %w", err)
}
if len(batch) == 0 {
break
}
all = append(all, batch...)
// Safety cap — 5000 repos should be more than enough.
if len(all) >= 5000 || len(batch) < 100 {
break
}
page++
}
// Sort by pushed_at descending (newest first).
sort.Slice(all, func(i, j int) bool {
return all[i].PushedAt.After(all[j].PushedAt)
})
result := make([]models.GitHubRepo, len(all))
for i, r := range all {
result[i] = models.GitHubRepo{
FullName: r.FullName,
Name: r.Name,
Owner: r.Owner.Login,
Description: r.Description,
HTMLURL: r.HTMLURL,
Private: r.Private,
Fork: r.Fork,
Archived: r.Archived,
Language: r.Language,
Stars: r.Stars,
PushedAt: r.PushedAt.Format(time.RFC3339),
UpdatedAt: r.UpdatedAt.Format(time.RFC3339),
}
}
return result, 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"
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("contacting GitHub API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("token validation failed (HTTP %d)", resp.StatusCode)
}
var user struct {
Login string `json:"login"`
}
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return "", fmt.Errorf("decoding user: %w", err)
}
return user.Login, nil
}