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:
@@ -618,6 +618,12 @@ func (s *Service) GetAppInfo() map[string]string {
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshGitHubRepos triggers an on-demand refresh of GitHub repositories for
|
||||
// the given session. The updated list is also emitted as a Wails event.
|
||||
func (s *Service) RefreshGitHubRepos(sessionID string) ([]models.GitHubRepo, error) {
|
||||
return s.sessions.FetchGitHubRepos(sessionID)
|
||||
}
|
||||
|
||||
// TestConnection performs a basic TCP reachability check to the given host and port.
|
||||
// This verifies the network path without establishing a full protocol session.
|
||||
func (s *Service) TestConnection(host string, port int) (map[string]interface{}, error) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const (
|
||||
DefaultSSHPort = 22
|
||||
DefaultTelnetPort = 23
|
||||
DefaultRAWPort = 23
|
||||
DefaultGitHubPort = 443
|
||||
)
|
||||
|
||||
// Default timeout values (in seconds).
|
||||
@@ -96,6 +97,14 @@ func NewProfile(protocol Protocol) Profile {
|
||||
Encoding: "utf-8",
|
||||
PayloadPresets: []PayloadPreset{},
|
||||
}
|
||||
case ProtocolGitHub:
|
||||
p.Port = DefaultGitHubPort
|
||||
p.Host = "github.com"
|
||||
p.GitHub = &GitHubConfig{
|
||||
BaseURL: "https://api.github.com",
|
||||
SecretMode: SecretModeSaved,
|
||||
RefreshIntervalSecs: 300,
|
||||
}
|
||||
}
|
||||
|
||||
return p
|
||||
|
||||
@@ -11,6 +11,7 @@ const (
|
||||
ProtocolSSH Protocol = "ssh"
|
||||
ProtocolTelnet Protocol = "telnet"
|
||||
ProtocolRAW Protocol = "raw"
|
||||
ProtocolGitHub Protocol = "github"
|
||||
)
|
||||
|
||||
// HostKeyPolicy determines how unknown or changed SSH host keys are handled.
|
||||
@@ -76,6 +77,9 @@ type Profile struct {
|
||||
|
||||
// RAW-specific configuration
|
||||
RAW *RAWConfig `json:"raw,omitempty"`
|
||||
|
||||
// GitHub-specific configuration
|
||||
GitHub *GitHubConfig `json:"github,omitempty"`
|
||||
}
|
||||
|
||||
// SSHConfig holds SSH-specific settings for a connection profile.
|
||||
@@ -132,6 +136,36 @@ type PayloadPreset struct {
|
||||
Format string `json:"format"` // text, hex, escaped
|
||||
}
|
||||
|
||||
// GitHubConfig holds GitHub API-specific settings for a connection profile.
|
||||
type GitHubConfig struct {
|
||||
// BaseURL is the GitHub API base (e.g. "https://api.github.com" for
|
||||
// github.com, or "https://github.example.com/api/v3" for GHE).
|
||||
BaseURL string `json:"baseURL"`
|
||||
|
||||
// SecretMode controls how the personal access token is stored.
|
||||
SecretMode SecretMode `json:"secretMode"`
|
||||
|
||||
// RefreshIntervalSecs controls the auto-refresh period in seconds.
|
||||
// Defaults to 300 (5 minutes).
|
||||
RefreshIntervalSecs int `json:"refreshIntervalSecs"`
|
||||
}
|
||||
|
||||
// GitHubRepo is a single repository returned to the frontend for display.
|
||||
type GitHubRepo struct {
|
||||
FullName string `json:"fullName"`
|
||||
Name string `json:"name"`
|
||||
Owner string `json:"owner"`
|
||||
Description string `json:"description"`
|
||||
HTMLURL string `json:"htmlURL"`
|
||||
Private bool `json:"private"`
|
||||
Fork bool `json:"fork"`
|
||||
Archived bool `json:"archived"`
|
||||
Language string `json:"language"`
|
||||
Stars int `json:"stars"`
|
||||
PushedAt string `json:"pushedAt"` // ISO 8601
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// SessionState represents the lifecycle state of a connection session.
|
||||
type SessionState string
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// Package sessions orchestrates multiple simultaneous SSH, Telnet, and RAW TCP
|
||||
// sessions. It coordinates between protocol clients, the storage layer, the
|
||||
// secret store, and the Wails runtime for event emission.
|
||||
// Package sessions orchestrates multiple simultaneous SSH, Telnet, RAW TCP,
|
||||
// and GitHub sessions. It coordinates between protocol clients, the storage
|
||||
// layer, the secret store, and the Wails runtime for event emission.
|
||||
package sessions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"sync"
|
||||
@@ -13,14 +14,13 @@ import (
|
||||
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"github.com/copilot-ssh-client/app/internal/audit"
|
||||
"github.com/copilot-ssh-client/app/internal/githubclient"
|
||||
"github.com/copilot-ssh-client/app/internal/models"
|
||||
"github.com/copilot-ssh-client/app/internal/rawclient"
|
||||
"github.com/copilot-ssh-client/app/internal/secrets"
|
||||
"github.com/copilot-ssh-client/app/internal/sshclient"
|
||||
"github.com/copilot-ssh-client/app/internal/storage"
|
||||
"github.com/copilot-ssh-client/app/internal/telnetclient"
|
||||
|
||||
"context"
|
||||
)
|
||||
|
||||
// Session wraps a protocol client with metadata and state.
|
||||
@@ -29,6 +29,8 @@ type Session struct {
|
||||
sshClient *sshclient.Client
|
||||
telnetClient *telnetclient.Client
|
||||
rawClient *rawclient.Client
|
||||
githubClient *githubclient.Client
|
||||
githubCancel context.CancelFunc // stops the auto-refresh goroutine
|
||||
profile *models.Profile
|
||||
}
|
||||
|
||||
@@ -105,6 +107,8 @@ func (m *Manager) Connect(profile *models.Profile, getSecret func(prompt string)
|
||||
err = m.connectTelnet(sess)
|
||||
case models.ProtocolRAW:
|
||||
err = m.connectRAW(sess)
|
||||
case models.ProtocolGitHub:
|
||||
err = m.connectGitHub(sess, getSecret)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported protocol %q", profile.Protocol)
|
||||
}
|
||||
@@ -558,6 +562,112 @@ func (m *Manager) connectRAW(sess *Session) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// connectGitHub validates the token and performs the initial repository fetch.
|
||||
// It also starts a background goroutine for periodic refresh.
|
||||
func (m *Manager) connectGitHub(sess *Session, getSecret func(prompt string) (string, error)) error {
|
||||
profile := sess.profile
|
||||
ghConf := profile.GitHub
|
||||
if ghConf == nil {
|
||||
return fmt.Errorf("GitHub configuration missing on profile %q", profile.ID)
|
||||
}
|
||||
|
||||
// Resolve the access token using the standard secret flow.
|
||||
getToken := m.buildSecretCallback(profile.ID, "github-token", ghConf.SecretMode, getSecret)
|
||||
token, err := getToken()
|
||||
if err != nil {
|
||||
return fmt.Errorf("obtaining GitHub token: %w", err)
|
||||
}
|
||||
if token == "" {
|
||||
return fmt.Errorf("GitHub token cannot be empty")
|
||||
}
|
||||
|
||||
client := githubclient.New(ghConf.BaseURL, token)
|
||||
|
||||
// Validate the token.
|
||||
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())
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching repositories: %w", err)
|
||||
}
|
||||
|
||||
// Emit the initial repo list.
|
||||
m.emitEvent("session:github:repos", map[string]interface{}{
|
||||
"sessionID": sess.Info.ID,
|
||||
"login": login,
|
||||
"repos": repos,
|
||||
})
|
||||
|
||||
// Start the periodic refresh goroutine.
|
||||
interval := time.Duration(ghConf.RefreshIntervalSecs) * time.Second
|
||||
if interval < 60*time.Second {
|
||||
interval = 5 * time.Minute
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
sess.githubCancel = cancel
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
updated, fetchErr := client.FetchRepos(ctx)
|
||||
if fetchErr != nil {
|
||||
m.emitEvent("session:github:error", map[string]interface{}{
|
||||
"sessionID": sess.Info.ID,
|
||||
"error": fetchErr.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
m.emitEvent("session:github:repos", map[string]interface{}{
|
||||
"sessionID": sess.Info.ID,
|
||||
"login": login,
|
||||
"repos": updated,
|
||||
})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FetchGitHubRepos triggers an on-demand refresh of GitHub repos for a session.
|
||||
func (m *Manager) FetchGitHubRepos(sessionID string) ([]models.GitHubRepo, error) {
|
||||
m.mu.RLock()
|
||||
sess, ok := m.sessions[sessionID]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("sessions: session %q not found", sessionID)
|
||||
}
|
||||
if sess.githubClient == nil {
|
||||
return nil, fmt.Errorf("sessions: session %q is not a GitHub connection", sessionID)
|
||||
}
|
||||
|
||||
repos, err := sess.githubClient.FetchRepos(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching repos: %w", err)
|
||||
}
|
||||
|
||||
m.emitEvent("session:github:repos", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"repos": repos,
|
||||
})
|
||||
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -645,6 +755,9 @@ func (m *Manager) closeSession(sess *Session) {
|
||||
if sess.rawClient != nil {
|
||||
sess.rawClient.Close()
|
||||
}
|
||||
if sess.githubCancel != nil {
|
||||
sess.githubCancel()
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
sess.Info.State = models.SessionDisconnected
|
||||
|
||||
Reference in New Issue
Block a user