feat: remove unnecessary files
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.5",
|
||||
"private": false,
|
||||
"description": "NPX launcher for Kandev",
|
||||
"license": "UNLICENSED",
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "commonjs",
|
||||
"bin": {
|
||||
"kandev": "bin/cli.js"
|
||||
|
||||
@@ -1,915 +0,0 @@
|
||||
# Plan 01: Secrets Infrastructure
|
||||
|
||||
> Encrypted secrets store for API keys, tokens, and sensitive configuration.
|
||||
> Foundation for Sprites.dev API key, future SSH keys, and third-party service tokens.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Architecture](#architecture)
|
||||
3. [Master Key Management](#master-key-management)
|
||||
4. [Encryption Scheme](#encryption-scheme)
|
||||
5. [Backend Implementation](#backend-implementation)
|
||||
6. [API Endpoints](#api-endpoints)
|
||||
7. [Frontend Implementation](#frontend-implementation)
|
||||
8. [Integration with Credential Manager](#integration-with-credential-manager)
|
||||
9. [Migration & Rollout](#migration--rollout)
|
||||
10. [Security Considerations](#security-considerations)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Kandev needs a way to store user secrets (API keys, tokens) encrypted at rest. Currently, credentials are sourced from environment variables (`EnvProvider`) or a JSON file (`FileProvider`) — neither supports encrypted storage or user-managed secrets via the UI.
|
||||
|
||||
### Goals
|
||||
|
||||
- Store secrets encrypted at rest in SQLite using AES-256-GCM
|
||||
- Master key file at `~/.kandev/master.key` (auto-generated, 0600 permissions)
|
||||
- `SecretStore` interface abstractable to OS keyring in the future
|
||||
- Integrate as a `CredentialProvider` in the existing credential manager chain
|
||||
- API endpoints for CRUD (never return plaintext in list operations)
|
||||
- Frontend settings page for managing secrets
|
||||
|
||||
### Non-Goals (This Phase)
|
||||
|
||||
- OS keyring integration (future)
|
||||
- Secret rotation policies
|
||||
- Multi-user / team secret sharing
|
||||
- Hardware security module (HSM) support
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (Settings) │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────────────┐ │
|
||||
│ │ Secrets Page │ │ Secret Form │ │ Secret List (masked) │ │
|
||||
│ └──────┬───────┘ └───────┬───────┘ └──────────┬───────────────┘ │
|
||||
│ │ │ │ │
|
||||
└─────────┼──────────────────┼──────────────────────┼─────────────────┘
|
||||
│ HTTP/WS │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Backend API Layer │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ secrets/handlers/handlers.go │ │
|
||||
│ │ POST /api/v1/secrets → Create secret │ │
|
||||
│ │ GET /api/v1/secrets → List secrets (names only) │ │
|
||||
│ │ GET /api/v1/secrets/:id → Get secret metadata │ │
|
||||
│ │ PUT /api/v1/secrets/:id → Update secret value │ │
|
||||
│ │ DELETE /api/v1/secrets/:id → Delete secret │ │
|
||||
│ │ POST /api/v1/secrets/:id/reveal → Get plaintext (auth req) │ │
|
||||
│ └──────────────────────┬─────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────▼─────────────────────────────────────────┐ │
|
||||
│ │ secrets/service.go │ │
|
||||
│ │ SecretService — business logic, validation, categorization │ │
|
||||
│ └──────────────────────┬─────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────▼─────────────────────────────────────────┐ │
|
||||
│ │ secrets/store.go (SecretStore interface) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ SQLiteSecretStore (default) │ │ │
|
||||
│ │ │ - Encrypts with AES-256-GCM before writing to DB │ │ │
|
||||
│ │ │ - Decrypts on read │ │ │
|
||||
│ │ │ - Uses master key from MasterKeyProvider │ │ │
|
||||
│ │ └─────────────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ KeyringSecretStore (future) │ │ │
|
||||
│ │ │ - OS keyring via go-keyring │ │ │
|
||||
│ │ └─────────────────────────────────────────────────────────┘ │ │
|
||||
│ └──────────────────────┬─────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────▼─────────────────────────────────────────┐ │
|
||||
│ │ secrets/crypto.go │ │
|
||||
│ │ MasterKeyProvider — loads/generates ~/.kandev/master.key │ │
|
||||
│ │ Encrypt(plaintext, key) → (ciphertext, nonce) │ │
|
||||
│ │ Decrypt(ciphertext, nonce, key) → plaintext │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────▼─────────────────────────────────────────┐ │
|
||||
│ │ credentials/secret_store_provider.go │ │
|
||||
│ │ SecretStoreProvider implements CredentialProvider │ │
|
||||
│ │ - Bridges SecretStore → credential manager chain │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ SQLite Database │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ secrets table │ │
|
||||
│ │ ┌──────────┬────────┬───────────────┬───────┬─────────────┐ │ │
|
||||
│ │ │ id (PK) │ name │ encrypted_val │ nonce │ category │ │ │
|
||||
│ │ │ TEXT │ TEXT │ BLOB │ BLOB │ TEXT │ │ │
|
||||
│ │ ├──────────┼────────┼───────────────┼───────┼─────────────┤ │ │
|
||||
│ │ │ metadata │ env_key│ created_at │ │ updated_at │ │ │
|
||||
│ │ │ TEXT/JSON│ TEXT │ TIMESTAMP │ │ TIMESTAMP │ │ │
|
||||
│ │ └──────────┴────────┴───────────────┴───────┴─────────────┘ │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Credential Resolution Chain
|
||||
|
||||
```
|
||||
GetCredential("SPRITES_API_TOKEN")
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────┐
|
||||
│ 1. Check cache │ ─── hit ──→ return cached
|
||||
│ (in-memory) │
|
||||
└────────────┬───────────────┘
|
||||
│ miss
|
||||
▼
|
||||
┌────────────────────────────┐
|
||||
│ 2. SecretStoreProvider │ ─── found ──→ cache + return
|
||||
│ (encrypted DB secrets) │
|
||||
└────────────┬───────────────┘
|
||||
│ not found
|
||||
▼
|
||||
┌────────────────────────────┐
|
||||
│ 3. EnvProvider │ ─── found ──→ cache + return
|
||||
│ (environment variables) │
|
||||
└────────────┬───────────────┘
|
||||
│ not found
|
||||
▼
|
||||
┌────────────────────────────┐
|
||||
│ 4. FileProvider │ ─── found ──→ cache + return
|
||||
│ (JSON credentials file) │
|
||||
└────────────┬───────────────┘
|
||||
│ not found
|
||||
▼
|
||||
return error
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Master Key Management
|
||||
|
||||
### Key Generation & Storage
|
||||
|
||||
```
|
||||
~/.kandev/
|
||||
├── master.key # 32-byte random key, 0600 permissions
|
||||
├── data/ # SQLite database (existing)
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Implementation: `secrets/crypto.go`
|
||||
|
||||
```go
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
MasterKeyFile = "master.key"
|
||||
MasterKeySize = 32 // AES-256
|
||||
)
|
||||
|
||||
// MasterKeyProvider manages the master encryption key.
|
||||
type MasterKeyProvider struct {
|
||||
keyPath string
|
||||
key []byte
|
||||
}
|
||||
|
||||
// NewMasterKeyProvider creates a provider that loads or generates
|
||||
// the master key from the given kandev config directory.
|
||||
func NewMasterKeyProvider(kandevDir string) (*MasterKeyProvider, error) {
|
||||
keyPath := filepath.Join(kandevDir, MasterKeyFile)
|
||||
provider := &MasterKeyProvider{keyPath: keyPath}
|
||||
|
||||
if err := provider.loadOrGenerate(); err != nil {
|
||||
return nil, fmt.Errorf("master key init: %w", err)
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func (p *MasterKeyProvider) loadOrGenerate() error {
|
||||
// Try to load existing key
|
||||
data, err := os.ReadFile(p.keyPath)
|
||||
if err == nil && len(data) == MasterKeySize {
|
||||
p.key = data
|
||||
return nil
|
||||
}
|
||||
|
||||
// Generate new key
|
||||
key := make([]byte, MasterKeySize)
|
||||
if _, err := io.ReadFull(rand.Reader, key); err != nil {
|
||||
return fmt.Errorf("generate key: %w", err)
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(p.keyPath), 0700); err != nil {
|
||||
return fmt.Errorf("create key dir: %w", err)
|
||||
}
|
||||
|
||||
// Write with restrictive permissions
|
||||
if err := os.WriteFile(p.keyPath, key, 0600); err != nil {
|
||||
return fmt.Errorf("write key: %w", err)
|
||||
}
|
||||
|
||||
p.key = key
|
||||
return nil
|
||||
}
|
||||
|
||||
// Key returns the master key bytes. Never log this.
|
||||
func (p *MasterKeyProvider) Key() []byte {
|
||||
return p.key
|
||||
}
|
||||
```
|
||||
|
||||
### Encryption Functions
|
||||
|
||||
```go
|
||||
// Encrypt encrypts plaintext using AES-256-GCM with a random nonce.
|
||||
// Returns (ciphertext, nonce, error).
|
||||
func Encrypt(plaintext []byte, key []byte) ([]byte, []byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create GCM: %w", err)
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, nil, fmt.Errorf("generate nonce: %w", err)
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
|
||||
return ciphertext, nonce, nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts ciphertext using AES-256-GCM.
|
||||
func Decrypt(ciphertext, nonce, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create GCM: %w", err)
|
||||
}
|
||||
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt: %w", err)
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Encryption Scheme
|
||||
|
||||
### AES-256-GCM Details
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Encryption Process │
|
||||
│ │
|
||||
│ plaintext: "sk-ant-api03-abc..." │
|
||||
│ master_key: [32 bytes from ~/.kandev/master.key] │
|
||||
│ │
|
||||
│ 1. Generate random nonce (12 bytes) │
|
||||
│ 2. AES-256-GCM encrypt: │
|
||||
│ ciphertext = GCM.Seal(nonce, plaintext, master_key) │
|
||||
│ 3. Store: (ciphertext, nonce) in DB │
|
||||
│ │
|
||||
│ Decryption: │
|
||||
│ 1. Read (ciphertext, nonce) from DB │
|
||||
│ 2. plaintext = GCM.Open(nonce, ciphertext, master_key) │
|
||||
│ │
|
||||
│ Properties: │
|
||||
│ - Authenticated encryption (integrity + confidentiality) │
|
||||
│ - Per-secret random nonce prevents ciphertext correlation │
|
||||
│ - 128-bit authentication tag │
|
||||
│ - Master key never leaves memory / ~/.kandev/master.key │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
### Package Structure
|
||||
|
||||
```
|
||||
apps/backend/internal/secrets/
|
||||
├── crypto.go # MasterKeyProvider, Encrypt(), Decrypt()
|
||||
├── models.go # Secret model, categories, DTOs
|
||||
├── store.go # SecretStore interface
|
||||
├── sqlite_store.go # SQLite implementation
|
||||
├── service.go # Business logic, validation
|
||||
├── handlers.go # HTTP/WS handlers
|
||||
└── provider.go # CredentialProvider bridge (SecretStoreProvider)
|
||||
```
|
||||
|
||||
### Models: `secrets/models.go`
|
||||
|
||||
```go
|
||||
package secrets
|
||||
|
||||
import "time"
|
||||
|
||||
// SecretCategory groups secrets by purpose.
|
||||
type SecretCategory string
|
||||
|
||||
const (
|
||||
CategoryAPIKey SecretCategory = "api_key" // LLM provider keys
|
||||
CategoryServiceToken SecretCategory = "service_token" // Sprites, exe.dev, etc.
|
||||
CategorySSHKey SecretCategory = "ssh_key" // SSH private keys
|
||||
CategoryCustom SecretCategory = "custom" // User-defined
|
||||
)
|
||||
|
||||
// Secret represents a stored secret (decrypted in-memory representation).
|
||||
type Secret struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"` // Human-readable name
|
||||
EnvKey string `json:"env_key"` // Env var name (e.g., SPRITES_API_TOKEN)
|
||||
Category SecretCategory `json:"category"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"` // e.g., {"service": "sprites.dev"}
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// Value is NEVER included in list/get responses. Only via Reveal().
|
||||
}
|
||||
|
||||
// SecretWithValue is only used internally and for create/update operations.
|
||||
type SecretWithValue struct {
|
||||
Secret
|
||||
Value string `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
// SecretListItem is returned by List() — never contains the value.
|
||||
type SecretListItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
EnvKey string `json:"env_key"`
|
||||
Category SecretCategory `json:"category"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
HasValue bool `json:"has_value"` // true if a value is stored
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
```
|
||||
|
||||
### Store Interface: `secrets/store.go`
|
||||
|
||||
```go
|
||||
package secrets
|
||||
|
||||
import "context"
|
||||
|
||||
// SecretStore abstracts secret storage. Implementations handle
|
||||
// encryption/decryption internally.
|
||||
type SecretStore interface {
|
||||
// Create stores a new secret (encrypts the value).
|
||||
Create(ctx context.Context, secret *SecretWithValue) error
|
||||
|
||||
// Get retrieves secret metadata (without value).
|
||||
Get(ctx context.Context, id string) (*Secret, error)
|
||||
|
||||
// GetByEnvKey retrieves secret metadata by env key name.
|
||||
GetByEnvKey(ctx context.Context, envKey string) (*Secret, error)
|
||||
|
||||
// Reveal retrieves the decrypted value of a secret.
|
||||
Reveal(ctx context.Context, id string) (string, error)
|
||||
|
||||
// RevealByEnvKey retrieves the decrypted value by env key name.
|
||||
RevealByEnvKey(ctx context.Context, envKey string) (string, error)
|
||||
|
||||
// Update updates a secret's value and/or metadata.
|
||||
Update(ctx context.Context, id string, secret *SecretWithValue) error
|
||||
|
||||
// Delete permanently removes a secret.
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
// List returns all secrets without values.
|
||||
List(ctx context.Context) ([]*SecretListItem, error)
|
||||
|
||||
// ListByCategory returns secrets filtered by category.
|
||||
ListByCategory(ctx context.Context, category SecretCategory) ([]*SecretListItem, error)
|
||||
|
||||
// Close releases resources.
|
||||
Close() error
|
||||
}
|
||||
```
|
||||
|
||||
### SQLite Implementation: `secrets/sqlite_store.go`
|
||||
|
||||
```go
|
||||
// SQLiteSecretStore implements SecretStore with AES-256-GCM encryption.
|
||||
type SQLiteSecretStore struct {
|
||||
db *sqlx.DB // writer
|
||||
ro *sqlx.DB // reader
|
||||
crypto *MasterKeyProvider
|
||||
logger *logger.Logger
|
||||
}
|
||||
```
|
||||
|
||||
**Database Schema:**
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS secrets (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
env_key TEXT NOT NULL UNIQUE,
|
||||
encrypted_value BLOB NOT NULL,
|
||||
nonce BLOB NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'custom',
|
||||
metadata TEXT DEFAULT '{}',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_secrets_env_key ON secrets(env_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_secrets_category ON secrets(category);
|
||||
```
|
||||
|
||||
**Key operations:**
|
||||
|
||||
```go
|
||||
func (s *SQLiteSecretStore) Create(ctx context.Context, secret *SecretWithValue) error {
|
||||
// 1. Validate: name and env_key not empty, env_key unique
|
||||
// 2. Encrypt: ciphertext, nonce, err := Encrypt([]byte(secret.Value), s.crypto.Key())
|
||||
// 3. INSERT INTO secrets (id, name, env_key, encrypted_value, nonce, category, metadata, ...)
|
||||
}
|
||||
|
||||
func (s *SQLiteSecretStore) Reveal(ctx context.Context, id string) (string, error) {
|
||||
// 1. SELECT encrypted_value, nonce FROM secrets WHERE id = ?
|
||||
// 2. plaintext, err := Decrypt(ciphertext, nonce, s.crypto.Key())
|
||||
// 3. return string(plaintext), nil
|
||||
}
|
||||
|
||||
func (s *SQLiteSecretStore) RevealByEnvKey(ctx context.Context, envKey string) (string, error) {
|
||||
// 1. SELECT encrypted_value, nonce FROM secrets WHERE env_key = ?
|
||||
// 2. Decrypt and return
|
||||
}
|
||||
```
|
||||
|
||||
### Credential Provider Bridge: `secrets/provider.go`
|
||||
|
||||
```go
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/kandev/kandev/internal/agent/credentials"
|
||||
)
|
||||
|
||||
// SecretStoreProvider bridges SecretStore into the credential provider chain.
|
||||
// It implements credentials.CredentialProvider.
|
||||
type SecretStoreProvider struct {
|
||||
store SecretStore
|
||||
}
|
||||
|
||||
func NewSecretStoreProvider(store SecretStore) *SecretStoreProvider {
|
||||
return &SecretStoreProvider{store: store}
|
||||
}
|
||||
|
||||
func (p *SecretStoreProvider) Name() string {
|
||||
return "secret_store"
|
||||
}
|
||||
|
||||
func (p *SecretStoreProvider) GetCredential(ctx context.Context, key string) (*credentials.Credential, error) {
|
||||
value, err := p.store.RevealByEnvKey(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &credentials.Credential{
|
||||
Key: key,
|
||||
Value: value,
|
||||
Source: "secret_store",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *SecretStoreProvider) ListAvailable(ctx context.Context) ([]string, error) {
|
||||
items, err := p.store.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := make([]string, len(items))
|
||||
for i, item := range items {
|
||||
keys[i] = item.EnvKey
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Service: `secrets/service.go`
|
||||
|
||||
```go
|
||||
type Service struct {
|
||||
store SecretStore
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// Validation rules:
|
||||
// - Name: 1-100 chars, unique
|
||||
// - EnvKey: uppercase, underscores, unique (e.g., SPRITES_API_TOKEN)
|
||||
// - Value: 1-10000 chars
|
||||
// - Category: must be valid SecretCategory
|
||||
```
|
||||
|
||||
### Handlers: `secrets/handlers.go`
|
||||
|
||||
```go
|
||||
type Handler struct {
|
||||
service *Service
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Route("/api/v1/secrets", func(r chi.Router) {
|
||||
r.Post("/", h.CreateSecret)
|
||||
r.Get("/", h.ListSecrets)
|
||||
r.Get("/{id}", h.GetSecret)
|
||||
r.Put("/{id}", h.UpdateSecret)
|
||||
r.Delete("/{id}", h.DeleteSecret)
|
||||
r.Post("/{id}/reveal", h.RevealSecret) // POST to avoid caching
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterWSActions(dispatcher ws.Dispatcher) {
|
||||
dispatcher.Register("secrets.list", h.WSListSecrets)
|
||||
dispatcher.Register("secrets.create", h.WSCreateSecret)
|
||||
dispatcher.Register("secrets.update", h.WSUpdateSecret)
|
||||
dispatcher.Register("secrets.delete", h.WSDeleteSecret)
|
||||
dispatcher.Register("secrets.reveal", h.WSRevealSecret)
|
||||
}
|
||||
```
|
||||
|
||||
### Startup Wiring: `cmd/kandev/storage.go` additions
|
||||
|
||||
```go
|
||||
// In provideRepositories():
|
||||
|
||||
// Initialize master key provider
|
||||
kandevDir := filepath.Join(os.Getenv("HOME"), ".kandev")
|
||||
masterKeyProvider, err := secrets.NewMasterKeyProvider(kandevDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("master key: %w", err)
|
||||
}
|
||||
|
||||
// Create secret store
|
||||
secretStore, cleanupSecrets, err := secrets.ProvideSQLiteStore(writer, reader, masterKeyProvider, log)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("secret store: %w", err)
|
||||
}
|
||||
|
||||
// In provideLifecycleManager() — register as credential provider:
|
||||
secretProvider := secrets.NewSecretStoreProvider(secretStore)
|
||||
credsMgr.AddProvider(secretProvider) // First in chain (highest priority)
|
||||
credsMgr.AddProvider(credentials.NewEnvProvider("KANDEV_"))
|
||||
// ... rest of providers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### HTTP Endpoints
|
||||
|
||||
| Method | Path | Request Body | Response | Description |
|
||||
|--------|------|-------------|----------|-------------|
|
||||
| `POST` | `/api/v1/secrets` | `{name, env_key, value, category?, metadata?}` | `{id, name, env_key, category, metadata, created_at}` | Create secret |
|
||||
| `GET` | `/api/v1/secrets` | — | `[{id, name, env_key, category, has_value, ...}]` | List (no values) |
|
||||
| `GET` | `/api/v1/secrets/:id` | — | `{id, name, env_key, category, metadata, ...}` | Get metadata |
|
||||
| `PUT` | `/api/v1/secrets/:id` | `{name?, value?, category?, metadata?}` | `{id, name, env_key, ...}` | Update |
|
||||
| `DELETE` | `/api/v1/secrets/:id` | — | `204 No Content` | Delete |
|
||||
| `POST` | `/api/v1/secrets/:id/reveal` | — | `{value}` | Get plaintext |
|
||||
|
||||
### WebSocket Actions
|
||||
|
||||
| Action | Payload | Response | Description |
|
||||
|--------|---------|----------|-------------|
|
||||
| `secrets.list` | `{}` | `[SecretListItem]` | List secrets |
|
||||
| `secrets.create` | `{name, env_key, value, category?}` | `SecretListItem` | Create |
|
||||
| `secrets.update` | `{id, name?, value?, category?}` | `SecretListItem` | Update |
|
||||
| `secrets.delete` | `{id}` | `{success: true}` | Delete |
|
||||
| `secrets.reveal` | `{id}` | `{value}` | Get plaintext |
|
||||
|
||||
### Request/Response DTOs
|
||||
|
||||
```go
|
||||
// Create
|
||||
type CreateSecretRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||
EnvKey string `json:"env_key" validate:"required,env_key_format"`
|
||||
Value string `json:"value" validate:"required,min=1,max=10000"`
|
||||
Category SecretCategory `json:"category,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type CreateSecretResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
EnvKey string `json:"env_key"`
|
||||
Category SecretCategory `json:"category"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Reveal (POST to avoid URL caching of sensitive data)
|
||||
type RevealSecretResponse struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
### New Files
|
||||
|
||||
```
|
||||
apps/web/
|
||||
├── app/settings/secrets/
|
||||
│ └── page.tsx # Secrets settings page route
|
||||
├── components/settings/
|
||||
│ ├── secrets-page.tsx # Secrets management page component
|
||||
│ └── secret-form-dialog.tsx # Create/edit secret dialog
|
||||
├── lib/api/domains/
|
||||
│ └── secrets-api.ts # API client for secrets endpoints
|
||||
├── lib/types/
|
||||
│ └── http-secrets.ts # TypeScript types for secrets
|
||||
├── lib/ws/handlers/
|
||||
│ └── secrets.ts # WS event handlers
|
||||
└── hooks/domains/settings/
|
||||
└── use-secrets.ts # Secrets data hook
|
||||
```
|
||||
|
||||
### TypeScript Types: `lib/types/http-secrets.ts`
|
||||
|
||||
```typescript
|
||||
export type SecretCategory = 'api_key' | 'service_token' | 'ssh_key' | 'custom';
|
||||
|
||||
export interface SecretListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
env_key: string;
|
||||
category: SecretCategory;
|
||||
metadata?: Record<string, string>;
|
||||
has_value: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateSecretRequest {
|
||||
name: string;
|
||||
env_key: string;
|
||||
value: string;
|
||||
category?: SecretCategory;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface UpdateSecretRequest {
|
||||
name?: string;
|
||||
value?: string;
|
||||
category?: SecretCategory;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
```
|
||||
|
||||
### API Client: `lib/api/domains/secrets-api.ts`
|
||||
|
||||
```typescript
|
||||
export const secretsApi = {
|
||||
list: () => fetchApi<SecretListItem[]>('/api/v1/secrets'),
|
||||
create: (req: CreateSecretRequest) => fetchApi<SecretListItem>('/api/v1/secrets', { method: 'POST', body: req }),
|
||||
update: (id: string, req: UpdateSecretRequest) => fetchApi<SecretListItem>(`/api/v1/secrets/${id}`, { method: 'PUT', body: req }),
|
||||
delete: (id: string) => fetchApi<void>(`/api/v1/secrets/${id}`, { method: 'DELETE' }),
|
||||
reveal: (id: string) => fetchApi<{ value: string }>(`/api/v1/secrets/${id}/reveal`, { method: 'POST' }),
|
||||
};
|
||||
```
|
||||
|
||||
### Store Slice Addition: `lib/state/slices/settings/settings-slice.ts`
|
||||
|
||||
```typescript
|
||||
// Add to SettingsSlice state:
|
||||
secrets: {
|
||||
items: SecretListItem[];
|
||||
loading: boolean;
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
// Add actions:
|
||||
setSecrets: (items: SecretListItem[]) => void;
|
||||
addSecret: (item: SecretListItem) => void;
|
||||
updateSecret: (id: string, item: Partial<SecretListItem>) => void;
|
||||
removeSecret: (id: string) => void;
|
||||
```
|
||||
|
||||
### Secrets Page Component: `components/settings/secrets-page.tsx`
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Secrets [+ Add] │
|
||||
│─────────────────────────────────────────────────────────────│
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ 🔑 Anthropic API Key │ │
|
||||
│ │ ANTHROPIC_API_KEY • api_key • ●●●●●●●●●●●● │ │
|
||||
│ │ [Reveal] [Delete] │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ 🔑 Sprites API Token │ │
|
||||
│ │ SPRITES_API_TOKEN • service_token • ●●●●●●●● │ │
|
||||
│ │ [Reveal] [Delete] │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ 🔑 GitHub Token │ │
|
||||
│ │ GITHUB_TOKEN • service_token • ●●●●●●●●●●●● │ │
|
||||
│ │ [Reveal] [Delete] │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Components:
|
||||
- Uses `@kandev/ui/card`, `@kandev/ui/button`, `@kandev/ui/dialog`, `@kandev/ui/badge`
|
||||
- Secret values shown as masked dots, with a "Reveal" button that calls the reveal endpoint
|
||||
- Create/Edit dialog with form validation (name, env_key format, value textarea)
|
||||
- Delete with confirmation dialog
|
||||
- Category badges with color coding
|
||||
|
||||
### Settings Sidebar Addition
|
||||
|
||||
Add "Secrets" item to the settings sidebar navigation, under a new "Security" section:
|
||||
|
||||
```typescript
|
||||
// In settings-app-sidebar.tsx
|
||||
{
|
||||
title: "Security",
|
||||
items: [
|
||||
{ title: "Secrets", url: "/settings/secrets", icon: IconKey }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration with Credential Manager
|
||||
|
||||
### Provider Registration Order
|
||||
|
||||
```go
|
||||
// In provideLifecycleManager() or startup wiring:
|
||||
credsMgr := credentials.NewManager(log)
|
||||
|
||||
// 1. Secret store (highest priority — user-managed secrets)
|
||||
credsMgr.AddProvider(secrets.NewSecretStoreProvider(secretStore))
|
||||
|
||||
// 2. Environment variables (existing behavior)
|
||||
credsMgr.AddProvider(credentials.NewEnvProvider("KANDEV_"))
|
||||
|
||||
// 3. Augment session provider (existing)
|
||||
credsMgr.AddProvider(credentials.NewAugmentSessionProvider())
|
||||
|
||||
// 4. File provider (existing, lowest priority)
|
||||
if credsFile := os.Getenv("KANDEV_CREDENTIALS_FILE"); credsFile != "" {
|
||||
credsMgr.AddProvider(credentials.NewFileProvider(credsFile))
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Example: Sprites API Token
|
||||
|
||||
```go
|
||||
// When creating a Sprites executor instance:
|
||||
token, err := credsMgr.GetCredentialValue(ctx, "SPRITES_API_TOKEN")
|
||||
if err != nil {
|
||||
return fmt.Errorf("sprites API token not configured: %w", err)
|
||||
}
|
||||
client := sprites.New(token)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration & Rollout
|
||||
|
||||
### Database Migration
|
||||
|
||||
The `secrets` table is created via `initSchema()` in the SQLite secret store (same pattern as other repos). No migration files needed — the table is created if it doesn't exist.
|
||||
|
||||
### Master Key Bootstrapping
|
||||
|
||||
```
|
||||
First startup after secrets feature:
|
||||
1. Check ~/.kandev/master.key exists
|
||||
2. If not, generate 32 random bytes, write with 0600 perms
|
||||
3. Log: "Master key generated at ~/.kandev/master.key"
|
||||
4. Warning: "Back up your master key — losing it means losing all stored secrets"
|
||||
|
||||
Subsequent startups:
|
||||
1. Load ~/.kandev/master.key
|
||||
2. Validate 32 bytes
|
||||
3. Initialize SQLiteSecretStore with loaded key
|
||||
```
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
- Existing `EnvProvider` and `FileProvider` continue to work unchanged
|
||||
- `SecretStoreProvider` is additive — no breaking changes to credential resolution
|
||||
- Users can gradually migrate from env vars to stored secrets
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Threat Model
|
||||
|
||||
| Threat | Mitigation |
|
||||
|--------|-----------|
|
||||
| DB file stolen | Secrets encrypted with AES-256-GCM |
|
||||
| Master key stolen | File permissions 0600; future: OS keyring |
|
||||
| Memory dump | Secrets in memory only during active use; cache cleared on shutdown |
|
||||
| API response leak | List endpoints never return values; reveal is POST |
|
||||
| Log injection | Secret values never logged (zap fields skip value) |
|
||||
| SQL injection | Parameterized queries via sqlx |
|
||||
|
||||
### Future: OS Keyring Migration
|
||||
|
||||
```go
|
||||
// KeyringSecretStore implements SecretStore using the OS keyring.
|
||||
// When implemented, secrets can be migrated:
|
||||
// 1. Read all secrets from SQLiteSecretStore (decrypt with master key)
|
||||
// 2. Write each to KeyringSecretStore
|
||||
// 3. Delete from SQLite
|
||||
// 4. Update config to use keyring backend
|
||||
```
|
||||
|
||||
### Master Key Backup Warning
|
||||
|
||||
On first generation, the backend logs a prominent warning and the frontend shows a one-time notification:
|
||||
|
||||
```
|
||||
⚠️ Master encryption key generated at ~/.kandev/master.key
|
||||
Back up this file — losing it means losing access to all stored secrets.
|
||||
The key is NOT recoverable.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Changed Summary
|
||||
|
||||
### New Files
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `apps/backend/internal/secrets/crypto.go` | MasterKeyProvider, Encrypt/Decrypt |
|
||||
| `apps/backend/internal/secrets/models.go` | Secret, SecretListItem, categories |
|
||||
| `apps/backend/internal/secrets/store.go` | SecretStore interface |
|
||||
| `apps/backend/internal/secrets/sqlite_store.go` | SQLite + encryption implementation |
|
||||
| `apps/backend/internal/secrets/service.go` | Validation, business logic |
|
||||
| `apps/backend/internal/secrets/handlers.go` | HTTP + WS handlers |
|
||||
| `apps/backend/internal/secrets/provider.go` | SecretStoreProvider (CredentialProvider bridge) |
|
||||
| `apps/web/app/settings/secrets/page.tsx` | Route page |
|
||||
| `apps/web/components/settings/secrets-page.tsx` | Secrets management UI |
|
||||
| `apps/web/components/settings/secret-form-dialog.tsx` | Create/edit dialog |
|
||||
| `apps/web/lib/api/domains/secrets-api.ts` | API client |
|
||||
| `apps/web/lib/types/http-secrets.ts` | TypeScript types |
|
||||
| `apps/web/lib/ws/handlers/secrets.ts` | WS event handlers |
|
||||
| `apps/web/hooks/domains/settings/use-secrets.ts` | Data hook |
|
||||
|
||||
### Modified Files
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `apps/backend/cmd/kandev/storage.go` | Init MasterKeyProvider + SecretStore |
|
||||
| `apps/backend/cmd/kandev/agents.go` | Register SecretStoreProvider in credential chain |
|
||||
| `apps/backend/cmd/kandev/routes.go` | Register secrets HTTP routes |
|
||||
| `apps/backend/cmd/kandev/websocket.go` | Register secrets WS actions |
|
||||
| `apps/web/lib/state/slices/settings/settings-slice.ts` | Add secrets state |
|
||||
| `apps/web/components/settings/settings-app-sidebar.tsx` | Add Secrets nav item |
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-02-20*
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,63 +0,0 @@
|
||||
also i think you're miss understanding a bit the sprites.dev executor:
|
||||
- the user should not be able to change its name, an Executor can't be changed by the user, he can only change the profile he created
|
||||
- connection and "Configure a SPRITES_API_TOKEN environment variable in the executor profile, referencing a secret with your Sprites.dev API token." is wrong, so the env variable is wrong, the secrets are now key value only, and
|
||||
the connection and running sprites that are in the page settings/executor/exec-sprites are wrong because they should belong to a sprites profile, not the executor
|
||||
|
||||
the executor page should only have the option for the user to create a new profile and list the existing profiles
|
||||
each executor profile is specific to that executor, it can't be "generic" or shared.
|
||||
|
||||
when creating an executor profile, lets say sprites.dev, the should needs to specify the api key (we should display a selector for him to choose from the secrets)
|
||||
|
||||
e.g for the sprites.dev profile the user should provide:
|
||||
- api key (select from secrets)
|
||||
- test connection check
|
||||
- env variables list (that the user can specity or use values from secrets), let the user know these variables will be inject for the runtime and setup script
|
||||
- prepare env script
|
||||
- cleanup env script
|
||||
actually i think its a good idea for the prepare env to be a bash script, with the steps we need (e.g install deps, git clone, setup permissions, etc) but using placeholders (templating) that we'll parse and run
|
||||
so the user can see clearly what will run and change it, our default scripts should have good comments to guide the user, and on the interface use the monaco editor with syntax highlighting/good editing capabilities
|
||||
(when opening a sprits.dev profile, we should list the running sprites and existing network policies and let the user stop/remove them)
|
||||
|
||||
|
||||
so this prepare env script would have several placeholders like (examples, you need to adapt):
|
||||
#!/bin/bash
|
||||
# install deps
|
||||
apt-install node npm ca-certificates
|
||||
|
||||
# setup git
|
||||
git clone {repository.ssh_url}
|
||||
cd {repository.name}
|
||||
# run repository setup script if configured
|
||||
{repository.setup.script}
|
||||
|
||||
{kandev.agentctl.install} # pulls from github or copies on make dev?
|
||||
{kandev.agentctl.start} # runs our agentctl process with our flags
|
||||
|
||||
each executor should have a default prepare script, that is filled when the user creates a new profile, he then can change it
|
||||
|
||||
lets go for each executor:
|
||||
- Local
|
||||
we should create a Local profile by default, its profile should only have a prepare setup script that runs git pull / fetch?
|
||||
(we should provide info to the user to let him know this will run on the chosen repo)
|
||||
|
||||
- Worktree
|
||||
we should create a Worktree profile by default, its profile should only have a prepare setup script that runs git pull / fetch?
|
||||
|
||||
- Docker
|
||||
we should create a "Local Docker" profile by default, its profile should have:
|
||||
- docker host value
|
||||
- type: local, remote (local for now)
|
||||
- base image
|
||||
- build custom image + editor (have a default dockerfile contents with at least node and git, and the user can change it)
|
||||
- build args
|
||||
- volumes
|
||||
should be a list, with our added volumes (like the repo, the agentctl binary, etc disabled) and the user can add more if he wants
|
||||
- env variables
|
||||
|
||||
(when opening a docker profile, we should list the running containers and let the user stop/remove them)
|
||||
|
||||
so we can use this docker executor as well to let the user a remote docker profile, he would need to provide a host and ssh config? or only a remote docker host? but what about authentication?
|
||||
anything the local and remote docker profiles should be here
|
||||
|
||||
so we can remove the concept of an environment entirely and the dedicated settings page for it, each executor profile will have its "environment"
|
||||
in the create task dialog, we list the existing executor profiles to run (not the executors type name, only the existing profiles)
|
||||
-744
@@ -1,744 +0,0 @@
|
||||
# GitHub Integration Plan
|
||||
|
||||
## Context
|
||||
|
||||
Kandev needs GitHub integration to support two automation workflows:
|
||||
|
||||
1. **PR Monitor** - When an agent creates a PR during a session, poll GitHub for CI failures and review comments. The user manually reviews this data in the session UI and can choose to send specific feedback to the agent for fixing.
|
||||
2. **PR Review Queue** - A background service that periodically polls GitHub for PRs awaiting the user's review, creates tasks in a workflow for each, and optionally auto-starts an agent to review them.
|
||||
|
||||
**Additional requirements**:
|
||||
- PRs must be associated with tasks in the DB (show PR icon on task list)
|
||||
- Session detail UI: show PR, comments, CI checks, approval status, pending review count, time-pending-review
|
||||
- PR analytics/stats: PRs created, reviewed, comments, CI results
|
||||
|
||||
**Design decisions** (confirmed with user):
|
||||
- Auth: `gh` CLI primary (existing `gh auth login`), PAT in secrets as fallback
|
||||
- PR detection: Auto-detect from git push events (no manual workflow config)
|
||||
- PR monitor: User manually reviews feedback, NOT auto-sent to agent
|
||||
- Review queue: Standalone background service with a manual "pull now" topbar button
|
||||
- Scope: Full stack (backend + frontend)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: GitHub Client Package
|
||||
|
||||
**New package**: `apps/backend/internal/github/`
|
||||
|
||||
### 1.1 `models.go` - Data models
|
||||
|
||||
```go
|
||||
// PR represents a GitHub Pull Request
|
||||
type PR struct {
|
||||
Number int
|
||||
Title string
|
||||
URL string
|
||||
HTMLURL string
|
||||
State string // open, closed, merged
|
||||
HeadBranch string
|
||||
BaseBranch string
|
||||
AuthorLogin string
|
||||
RepoOwner string
|
||||
RepoName string
|
||||
Draft bool
|
||||
Mergeable bool
|
||||
Additions int
|
||||
Deletions int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
MergedAt *time.Time
|
||||
ClosedAt *time.Time
|
||||
}
|
||||
|
||||
// PRReview represents a review on a PR
|
||||
type PRReview struct {
|
||||
ID int64
|
||||
Author string
|
||||
State string // APPROVED, CHANGES_REQUESTED, COMMENTED, PENDING, DISMISSED
|
||||
Body string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// PRComment represents a review comment on specific code
|
||||
type PRComment struct {
|
||||
ID int64
|
||||
Author string
|
||||
Body string
|
||||
Path string
|
||||
Line int
|
||||
Side string // LEFT, RIGHT
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
InReplyTo *int64
|
||||
}
|
||||
|
||||
// CheckRun represents a CI check result
|
||||
type CheckRun struct {
|
||||
Name string
|
||||
Status string // queued, in_progress, completed
|
||||
Conclusion string // success, failure, neutral, cancelled, timed_out, action_required, skipped
|
||||
HTMLURL string
|
||||
Output string // summary text
|
||||
StartedAt *time.Time
|
||||
CompletedAt *time.Time
|
||||
}
|
||||
|
||||
// PRFeedback aggregates all feedback for a PR (fetched live)
|
||||
type PRFeedback struct {
|
||||
PR *PR
|
||||
Reviews []PRReview
|
||||
Comments []PRComment
|
||||
Checks []CheckRun
|
||||
HasIssues bool // true if failing checks or changes_requested
|
||||
}
|
||||
|
||||
// PRWatch tracks active PR monitoring (session → PR)
|
||||
type PRWatch struct {
|
||||
ID string
|
||||
SessionID string
|
||||
TaskID string
|
||||
Owner string
|
||||
Repo string
|
||||
PRNumber int
|
||||
Branch string
|
||||
LastCheckedAt *time.Time
|
||||
LastCommentAt *time.Time
|
||||
LastCheckStatus string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// ReviewWatch configures periodic polling for PRs needing user's review
|
||||
type ReviewWatch struct {
|
||||
ID string
|
||||
WorkspaceID string
|
||||
WorkflowID string
|
||||
WorkflowStepID string
|
||||
RepoOwner string
|
||||
RepoName string
|
||||
AgentProfileID string
|
||||
ExecutorProfileID string
|
||||
Prompt string
|
||||
AutoStart bool
|
||||
Enabled bool
|
||||
PollIntervalSeconds int
|
||||
LastPolledAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// GitHubStatus represents GitHub connection status
|
||||
type GitHubStatus struct {
|
||||
Authenticated bool
|
||||
Username string
|
||||
AuthMethod string // "gh_cli", "pat", "none"
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 `client.go` - Interface
|
||||
|
||||
```go
|
||||
type Client interface {
|
||||
// Auth
|
||||
IsAuthenticated(ctx context.Context) (bool, error)
|
||||
GetAuthenticatedUser(ctx context.Context) (string, error)
|
||||
|
||||
// PR queries
|
||||
GetPR(ctx context.Context, owner, repo string, number int) (*PR, error)
|
||||
FindPRByBranch(ctx context.Context, owner, repo, branch string) (*PR, error)
|
||||
ListAuthoredPRs(ctx context.Context, owner, repo string) ([]*PR, error)
|
||||
ListReviewRequestedPRs(ctx context.Context, username string) ([]*PR, error)
|
||||
|
||||
// PR details
|
||||
ListPRReviews(ctx context.Context, owner, repo string, number int) ([]PRReview, error)
|
||||
ListPRComments(ctx context.Context, owner, repo string, number int, since *time.Time) ([]PRComment, error)
|
||||
ListCheckRuns(ctx context.Context, owner, repo, ref string) ([]CheckRun, error)
|
||||
|
||||
// Aggregated
|
||||
GetPRFeedback(ctx context.Context, owner, repo string, number int) (*PRFeedback, error)
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 `gh_client.go` - Primary (gh CLI)
|
||||
|
||||
Executes `gh` commands:
|
||||
- `gh pr list --json number,title,url,headRefName,baseRefName,author,state,createdAt,updatedAt,additions,deletions,isDraft,mergeable`
|
||||
- `gh pr view <number> --json reviews,comments`
|
||||
- `gh api repos/{owner}/{repo}/commits/{ref}/check-runs`
|
||||
- `gh api search/issues?q=type:pr+review-requested:@me+state:open`
|
||||
|
||||
Detect availability via `gh --version`. Parse JSON stdout.
|
||||
|
||||
### 1.4 `pat_client.go` - Fallback (PAT via net/http)
|
||||
|
||||
Uses `GITHUB_TOKEN` secret from secrets store. `Authorization: token <PAT>`. Rate limit header awareness.
|
||||
|
||||
### 1.5 `factory.go` - Client factory
|
||||
|
||||
Try `gh` CLI first, fall back to PAT from secrets store.
|
||||
|
||||
### 1.6 `provider.go` - DI provider
|
||||
|
||||
Standard `Provide(cfg, log, secretsSvc, db) (*Service, cleanup, error)` pattern.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Service + Persistence
|
||||
|
||||
### 2.1 `service.go` - Business logic
|
||||
|
||||
```go
|
||||
type Service struct {
|
||||
client Client
|
||||
store *Store
|
||||
eventBus bus.EventBus
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// PR Watch operations (for PR monitoring)
|
||||
func (s *Service) CreatePRWatch(ctx, sessionID, taskID, owner, repo string, prNumber int, branch string) (*PRWatch, error)
|
||||
func (s *Service) DeletePRWatch(ctx, id string) error
|
||||
func (s *Service) GetPRWatchBySession(ctx, sessionID string) (*PRWatch, error)
|
||||
func (s *Service) ListActivePRWatches(ctx) ([]*PRWatch, error)
|
||||
|
||||
// Task-PR association
|
||||
func (s *Service) AssociatePRWithTask(ctx, taskID, owner, repo string, prNumber int, prURL, prTitle, authorLogin string) (*TaskPR, error)
|
||||
func (s *Service) GetTaskPR(ctx, taskID string) (*TaskPR, error)
|
||||
func (s *Service) ListTaskPRs(ctx, taskIDs []string) (map[string]*TaskPR, error)
|
||||
func (s *Service) UpdateTaskPR(ctx, id string, state, mergeState string, ...) error
|
||||
|
||||
// PR feedback (fetched live, not stored)
|
||||
func (s *Service) GetPRFeedback(ctx, owner, repo string, number int) (*PRFeedback, error)
|
||||
|
||||
// Review Watch operations (for review queue)
|
||||
func (s *Service) CreateReviewWatch(ctx, req *CreateReviewWatchRequest) (*ReviewWatch, error)
|
||||
func (s *Service) UpdateReviewWatch(ctx, id string, req *UpdateReviewWatchRequest) error
|
||||
func (s *Service) DeleteReviewWatch(ctx, id string) error
|
||||
func (s *Service) ListReviewWatches(ctx, workspaceID string) ([]*ReviewWatch, error)
|
||||
|
||||
// Polling
|
||||
func (s *Service) CheckPRWatch(ctx, watch *PRWatch) (*PRFeedback, bool, error) // returns hasNewFeedback
|
||||
func (s *Service) CheckReviewWatch(ctx, watch *ReviewWatch) ([]*PR, error) // returns new PRs
|
||||
|
||||
// Manual fetch
|
||||
func (s *Service) TriggerReviewCheck(ctx, watchID string) ([]*PR, error)
|
||||
func (s *Service) TriggerAllReviewChecks(ctx, workspaceID string) (int, error) // returns count of new PRs found
|
||||
|
||||
// Status
|
||||
func (s *Service) GetStatus(ctx) (*GitHubStatus, error)
|
||||
```
|
||||
|
||||
### 2.2 `store.go` - SQLite persistence
|
||||
|
||||
Four tables (owned by github package):
|
||||
|
||||
```sql
|
||||
-- Tracks active PR monitoring (session → PR)
|
||||
CREATE TABLE IF NOT EXISTS github_pr_watches (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
task_id TEXT NOT NULL,
|
||||
owner TEXT NOT NULL,
|
||||
repo TEXT NOT NULL,
|
||||
pr_number INTEGER NOT NULL,
|
||||
branch TEXT NOT NULL,
|
||||
last_checked_at DATETIME,
|
||||
last_comment_at DATETIME,
|
||||
last_check_status TEXT DEFAULT '',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- Associates a PR with a task (1:1 for now, could be 1:many later)
|
||||
-- Used for: PR icon on task list, session detail PR view, stats
|
||||
CREATE TABLE IF NOT EXISTS github_task_prs (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
owner TEXT NOT NULL,
|
||||
repo TEXT NOT NULL,
|
||||
pr_number INTEGER NOT NULL,
|
||||
pr_url TEXT NOT NULL,
|
||||
pr_title TEXT NOT NULL,
|
||||
head_branch TEXT NOT NULL,
|
||||
base_branch TEXT NOT NULL,
|
||||
author_login TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'open', -- open, closed, merged
|
||||
review_state TEXT NOT NULL DEFAULT '', -- approved, changes_requested, pending, ''
|
||||
checks_state TEXT NOT NULL DEFAULT '', -- success, failure, pending, ''
|
||||
review_count INTEGER DEFAULT 0,
|
||||
pending_review_count INTEGER DEFAULT 0,
|
||||
comment_count INTEGER DEFAULT 0,
|
||||
additions INTEGER DEFAULT 0,
|
||||
deletions INTEGER DEFAULT 0,
|
||||
created_at DATETIME NOT NULL, -- PR creation time on GitHub
|
||||
merged_at DATETIME,
|
||||
closed_at DATETIME,
|
||||
last_synced_at DATETIME,
|
||||
updated_at DATETIME NOT NULL,
|
||||
UNIQUE(task_id, pr_number)
|
||||
);
|
||||
|
||||
-- Review watch configurations (workspace → repo polling)
|
||||
CREATE TABLE IF NOT EXISTS github_review_watches (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
workflow_id TEXT NOT NULL,
|
||||
workflow_step_id TEXT NOT NULL,
|
||||
repo_owner TEXT NOT NULL,
|
||||
repo_name TEXT NOT NULL,
|
||||
agent_profile_id TEXT NOT NULL,
|
||||
executor_profile_id TEXT NOT NULL,
|
||||
prompt TEXT DEFAULT '',
|
||||
auto_start BOOLEAN DEFAULT 0,
|
||||
enabled BOOLEAN DEFAULT 1,
|
||||
poll_interval_seconds INTEGER DEFAULT 300,
|
||||
last_polled_at DATETIME,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- Deduplication: which PRs we already created tasks for
|
||||
CREATE TABLE IF NOT EXISTS github_review_pr_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
review_watch_id TEXT NOT NULL,
|
||||
pr_number INTEGER NOT NULL,
|
||||
pr_url TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
UNIQUE(review_watch_id, pr_number)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Background Poller
|
||||
|
||||
### 3.1 `poller.go`
|
||||
|
||||
Two goroutine loops:
|
||||
|
||||
**PR Monitor Loop** (every 2 min):
|
||||
1. List all active `github_pr_watches`
|
||||
2. For each watch, fetch PR feedback (comments + checks) via client
|
||||
3. Compare with last known state (last_comment_at, last_check_status)
|
||||
4. If new comments or check state changes:
|
||||
- Update the `github_task_prs` record (review_state, checks_state, comment_count, etc.)
|
||||
- Publish `github.pr_feedback` event to event bus (for UI notification, NOT auto-prompt)
|
||||
- Update watch timestamps
|
||||
5. Also sync PR state (merged, closed) to `github_task_prs`
|
||||
|
||||
**Review Queue Loop** (every 5 min, per-watch configurable):
|
||||
1. List all enabled `github_review_watches`
|
||||
2. For each watch, call `ListReviewRequestedPRs` filtered to the watch's repo
|
||||
3. For each PR not already in `github_review_pr_tasks`:
|
||||
- Publish `github.new_pr_to_review` event
|
||||
- Orchestrator handler creates the task + associates the PR
|
||||
|
||||
Methods: `Start(ctx)`, `Stop()`, `TriggerReviewCheck(ctx, watchID)`, `TriggerAllReviewChecks(ctx, workspaceID)`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Event Bus + Orchestrator Integration
|
||||
|
||||
### 4.1 New event types
|
||||
|
||||
**File**: `apps/backend/internal/events/types.go`
|
||||
|
||||
```go
|
||||
// Event types for GitHub integration
|
||||
const (
|
||||
GitHubPRFeedback = "github.pr_feedback" // PR has new feedback (UI notification only)
|
||||
GitHubPRStateChanged = "github.pr_state_changed" // PR state changed (merged, closed, etc.)
|
||||
GitHubNewReviewPR = "github.new_pr_to_review" // New PR found needing review
|
||||
GitHubWatchEvent = "github.watch.event" // Watch created/deleted
|
||||
GitHubTaskPRUpdated = "github.task_pr.updated" // TaskPR record updated (for UI refresh)
|
||||
)
|
||||
```
|
||||
|
||||
### 4.2 PR auto-detection from git events
|
||||
|
||||
**File**: `apps/backend/internal/orchestrator/event_handlers_git.go`
|
||||
|
||||
In `handleGitStatusUpdate()`, detect push completion:
|
||||
- Track per-session state: when `Ahead` goes from `>0` to `0` with a `RemoteBranch` set, a push happened
|
||||
- After detecting push, call `githubService.Client().FindPRByBranch()` to check if a PR exists
|
||||
- If PR found and no watch exists for this session:
|
||||
1. Auto-create `PRWatch` via `githubService.CreatePRWatch()`
|
||||
2. Auto-create `TaskPR` association via `githubService.AssociatePRWithTask()`
|
||||
3. Publish `github.task_pr.updated` event (so task list shows PR icon)
|
||||
|
||||
**Lookup chain**:
|
||||
- `repoStore.GetTaskSession(sessionID)` → `RepositoryID`
|
||||
- `repoStore.GetRepository(repositoryID)` → `ProviderOwner`, `ProviderName`
|
||||
- `FindPRByBranch(providerOwner, providerName, branch)`
|
||||
|
||||
### 4.3 Orchestrator GitHub event handlers
|
||||
|
||||
**New file**: `apps/backend/internal/orchestrator/event_handlers_github.go`
|
||||
|
||||
```go
|
||||
// handlePRFeedback forwards PR feedback events to the frontend via WS.
|
||||
// The user manually decides whether to send feedback to the agent.
|
||||
// This does NOT auto-prompt the agent.
|
||||
func (s *Service) handlePRFeedback(ctx context.Context, event *bus.Event) {
|
||||
// Extract sessionID, feedback summary from event
|
||||
// Forward to WS for session UI notification
|
||||
// Frontend shows updated PR data in session detail panel
|
||||
}
|
||||
|
||||
// handleNewReviewPR creates a task for a PR needing review.
|
||||
func (s *Service) handleNewReviewPR(ctx context.Context, event *bus.Event) {
|
||||
// Extract PR data, ReviewWatch config
|
||||
// Create task in the watch's workflow at configured step
|
||||
// Associate PR with task via githubService.AssociatePRWithTask()
|
||||
// Record in github_review_pr_tasks for dedup
|
||||
// If auto_start, enqueue the task for agent execution
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 "Send to Agent" action
|
||||
|
||||
The session detail UI will have a "Send to Agent" button that lets the user compose a message to the agent including selected PR comments and/or CI failures. This uses the existing `PromptTask()` flow — the frontend sends a user message with the selected feedback context embedded.
|
||||
|
||||
### 4.5 Orchestrator wiring
|
||||
|
||||
**File**: `apps/backend/internal/orchestrator/service.go`
|
||||
- Add `githubService` field + `SetGitHubService()` setter
|
||||
- Add `pushTracker sync.Map` for per-session ahead-count tracking
|
||||
- Subscribe to `github.pr_feedback` and `github.new_pr_to_review` events in `Start()`
|
||||
|
||||
**File**: `apps/backend/cmd/kandev/services.go`
|
||||
- Add GitHub service to `Services` struct, initialize via `github.Provide()`
|
||||
- Wire into orchestrator, start poller
|
||||
|
||||
**File**: `apps/backend/cmd/kandev/main.go`
|
||||
- Register GitHub HTTP routes
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: HTTP API
|
||||
|
||||
### 5.1 `controller.go`
|
||||
|
||||
**Route group**: `/api/v1/github/`
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/status` | GET | Auth status (username, auth method) |
|
||||
| `/prs/authored` | GET | User's open PRs (query: owner, repo) |
|
||||
| `/prs/review-requested` | GET | PRs awaiting user's review |
|
||||
| `/prs/{owner}/{repo}/{number}` | GET | PR details with full feedback |
|
||||
| `/prs/{owner}/{repo}/{number}/checks` | GET | CI check status only |
|
||||
| `/prs/{owner}/{repo}/{number}/comments` | GET | PR comments only |
|
||||
| `/task-prs` | GET | List task-PR associations (query: task_ids) |
|
||||
| `/task-prs/{taskId}` | GET | Get PR association for a task |
|
||||
| `/watches/pr` | GET | List active PR watches |
|
||||
| `/watches/pr/{id}` | DELETE | Remove a PR watch |
|
||||
| `/watches/review` | GET | List review watches (query: workspace_id) |
|
||||
| `/watches/review` | POST | Create review watch |
|
||||
| `/watches/review/{id}` | PUT | Update review watch |
|
||||
| `/watches/review/{id}` | DELETE | Delete review watch |
|
||||
| `/watches/review/{id}/trigger` | POST | Manual "pull now" for one watch |
|
||||
| `/watches/review/trigger-all` | POST | Trigger all for workspace |
|
||||
| `/stats` | GET | PR stats (query: workspace_id, date range) |
|
||||
|
||||
### 5.2 `handlers.go` - WS handlers
|
||||
|
||||
| Action | Description |
|
||||
|--------|-------------|
|
||||
| `github.status` | Auth status |
|
||||
| `github.task_prs.list` | Get task-PR associations (batch) |
|
||||
| `github.pr_feedback.get` | Get live PR feedback for session |
|
||||
| `github.review_watches.list` | List review watches |
|
||||
| `github.review_watches.create` | Create review watch |
|
||||
| `github.review_watches.update` | Update review watch |
|
||||
| `github.review_watches.delete` | Delete review watch |
|
||||
| `github.review_watches.trigger` | Trigger single check |
|
||||
| `github.review_watches.trigger_all` | Trigger all for workspace |
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Frontend
|
||||
|
||||
### 6.1 Types: `apps/web/lib/types/github.ts`
|
||||
|
||||
```typescript
|
||||
interface GitHubStatus {
|
||||
authenticated: boolean;
|
||||
username: string;
|
||||
authMethod: 'gh_cli' | 'pat' | 'none';
|
||||
}
|
||||
|
||||
interface GitHubPR {
|
||||
number: number; title: string; url: string; htmlUrl: string;
|
||||
state: 'open' | 'closed' | 'merged';
|
||||
headBranch: string; baseBranch: string;
|
||||
authorLogin: string; repoOwner: string; repoName: string;
|
||||
draft: boolean; additions: number; deletions: number;
|
||||
createdAt: string; updatedAt: string;
|
||||
mergedAt: string | null; closedAt: string | null;
|
||||
}
|
||||
|
||||
interface TaskPR {
|
||||
id: string; taskId: string;
|
||||
owner: string; repo: string; prNumber: number;
|
||||
prUrl: string; prTitle: string;
|
||||
headBranch: string; baseBranch: string; authorLogin: string;
|
||||
state: 'open' | 'closed' | 'merged';
|
||||
reviewState: 'approved' | 'changes_requested' | 'pending' | '';
|
||||
checksState: 'success' | 'failure' | 'pending' | '';
|
||||
reviewCount: number; pendingReviewCount: number; commentCount: number;
|
||||
additions: number; deletions: number;
|
||||
createdAt: string; mergedAt: string | null; closedAt: string | null;
|
||||
lastSyncedAt: string | null;
|
||||
}
|
||||
|
||||
interface PRReview { id: number; author: string; state: string; body: string; createdAt: string; }
|
||||
interface PRComment { id: number; author: string; body: string; path: string; line: number; side: string; createdAt: string; inReplyTo: number | null; }
|
||||
interface CheckRun { name: string; status: string; conclusion: string; htmlUrl: string; output: string; }
|
||||
interface PRFeedback { pr: GitHubPR; reviews: PRReview[]; comments: PRComment[]; checks: CheckRun[]; hasIssues: boolean; }
|
||||
|
||||
interface PRWatch { id: string; sessionId: string; taskId: string; owner: string; repo: string; prNumber: number; branch: string; lastCheckedAt: string | null; }
|
||||
interface ReviewWatch { id: string; workspaceId: string; workflowId: string; workflowStepId: string; repoOwner: string; repoName: string; agentProfileId: string; executorProfileId: string; prompt: string; autoStart: boolean; enabled: boolean; pollIntervalSeconds: number; lastPolledAt: string | null; }
|
||||
```
|
||||
|
||||
### 6.2 API Client: `apps/web/lib/api/domains/github-api.ts`
|
||||
|
||||
Methods mirroring all HTTP endpoints.
|
||||
|
||||
### 6.3 Store: `apps/web/lib/state/slices/github/github-slice.ts`
|
||||
|
||||
Zustand slice:
|
||||
- `github.status` - auth status
|
||||
- `github.taskPRs.byTaskId` - PR associations per task (for task list PR icon)
|
||||
- `github.prWatches.bySessionId` - active PR watches per session
|
||||
- `github.reviewWatches.byWorkspaceId` - review watch configs
|
||||
|
||||
### 6.4 Hooks: `apps/web/hooks/domains/github/`
|
||||
|
||||
- `use-github-status.ts` - auth status
|
||||
- `use-task-pr.ts` - PR association for a task (or batch for task list)
|
||||
- `use-pr-feedback.ts` - live PR feedback for session detail
|
||||
- `use-review-watches.ts` - CRUD for review watches
|
||||
- `use-pr-watch.ts` - PR monitoring status per session
|
||||
|
||||
### 6.5 Task List: PR Icon
|
||||
|
||||
**File**: Modify task card/row components to show a PR icon (e.g., git-pull-request icon from lucide) when `taskPR` exists for that task. Color-coded: green (approved/passing), red (changes requested/failing), yellow (pending).
|
||||
|
||||
### 6.6 Settings Page: `apps/web/app/settings/general/github/page.tsx`
|
||||
|
||||
- **Connection Status card**: Auth method, username, `gh` CLI availability
|
||||
- **Review Watches table**: List watches with repo, workflow, status, last polled
|
||||
- **Create/Edit Review Watch dialog**: Repo selector (from workspace repos with `provider: "github"`), workflow+step selector, agent profile, executor profile, custom prompt, auto-start toggle, poll interval, enable/disable
|
||||
|
||||
### 6.7 Session Detail: PR Info in Topbar + PR Panel
|
||||
|
||||
**Session topbar** (`apps/web/components/github/pr-session-topbar.tsx`):
|
||||
In the session detail topbar (not the kanban topbar), show a compact PR summary when a PR is associated:
|
||||
- PR number + link (e.g., `#42`)
|
||||
- State badge (open/merged/closed)
|
||||
- CI status indicator (green check / red X / yellow spinner)
|
||||
- Review status (e.g., "2/3 approved", "changes requested")
|
||||
- Comment count badge
|
||||
- Clicking opens the full PR detail panel
|
||||
|
||||
**Full PR detail panel** (`apps/web/components/github/pr-detail-panel.tsx`):
|
||||
Expandable/drawer panel accessible from the session topbar:
|
||||
- **PR header**: Title, number, link, state badge, draft indicator
|
||||
- **Stats bar**: +additions/-deletions, time since created, time pending review
|
||||
- **Reviews section**: List of reviews with state (approved/changes_requested), author, body
|
||||
- **Pending reviewers**: Who hasn't reviewed yet, how long pending
|
||||
- **CI Checks section**: List of check runs with status badge (pass/fail/pending), link to details
|
||||
- **Comments section**: Threaded review comments with file path, line number, author
|
||||
- **"Send to Agent" button**: Opens a dialog to compose a message to the agent, with checkboxes to include specific comments/CI failures as context. Sends via normal `PromptTask` flow.
|
||||
|
||||
### 6.8 Kanban Homepage Topbar: Manual Pull Button
|
||||
|
||||
**Component**: `apps/web/components/github/refresh-reviews-button.tsx`
|
||||
|
||||
Button in the **kanban/homepage topbar** (NOT the session detail topbar):
|
||||
- Triggers `POST /watches/review/trigger-all` for active workspace
|
||||
- Shows toast with results ("Found 3 new PRs to review")
|
||||
- Only visible when review watches exist for the workspace
|
||||
|
||||
### 6.9 Stats
|
||||
|
||||
**Route**: `/api/v1/github/stats` (backend) + stats section in settings or dedicated page
|
||||
|
||||
**Backend** (`internal/github/stats.go`):
|
||||
|
||||
```go
|
||||
type PRStats struct {
|
||||
// Counts
|
||||
TotalPRsCreated int // PRs authored by user (from github_task_prs)
|
||||
TotalPRsReviewed int // PRs reviewed (tasks from review watches)
|
||||
TotalComments int // Total review comments across all PRs
|
||||
TotalChecksRun int // Total CI check runs tracked
|
||||
|
||||
// Rates
|
||||
CIPassRate float64 // % of PRs where all checks passed
|
||||
ApprovalRate float64 // % of PRs approved on first review cycle
|
||||
|
||||
// Timing (averages)
|
||||
AvgTimeToFirstReview time.Duration // Time from PR creation to first review
|
||||
AvgTimeToMerge time.Duration // Time from PR creation to merge
|
||||
AvgTimePendingReview time.Duration // Time PRs spent waiting for review
|
||||
|
||||
// By period (for charts)
|
||||
PRsByDay []DailyCount // PRs created per day
|
||||
ReviewsByDay []DailyCount // PRs reviewed per day
|
||||
CommentsByDay []DailyCount // Comments per day
|
||||
}
|
||||
|
||||
type DailyCount struct {
|
||||
Date string `json:"date"` // "2026-02-22"
|
||||
Count int `json:"count"`
|
||||
}
|
||||
```
|
||||
|
||||
Data sourced from `github_task_prs` table aggregations with date range filtering.
|
||||
|
||||
**Frontend** (`apps/web/components/github/pr-stats.tsx`):
|
||||
- Stats cards: PRs created, reviewed, avg time to merge, CI pass rate
|
||||
- Line charts: PRs over time, comments over time
|
||||
- Uses `@kandev/ui` Card, Badge for layout
|
||||
- Could be embedded in GitHub settings page or as a section in a workspace dashboard
|
||||
|
||||
---
|
||||
|
||||
## Files to Create
|
||||
|
||||
```
|
||||
apps/backend/internal/github/
|
||||
models.go # Data models (PR, Review, Comment, Check, Watch, TaskPR)
|
||||
client.go # Client interface
|
||||
gh_client.go # gh CLI implementation
|
||||
pat_client.go # PAT HTTP implementation
|
||||
factory.go # Client factory (gh CLI → PAT fallback)
|
||||
store.go # SQLite persistence (watches, task_prs, dedup)
|
||||
service.go # Business logic
|
||||
poller.go # Background polling loops
|
||||
controller.go # HTTP endpoints
|
||||
handlers.go # WS handlers
|
||||
provider.go # DI provider
|
||||
stats.go # PR analytics queries
|
||||
|
||||
apps/web/
|
||||
lib/types/github.ts
|
||||
lib/api/domains/github-api.ts
|
||||
lib/state/slices/github/
|
||||
index.ts
|
||||
github-slice.ts
|
||||
hooks/domains/github/
|
||||
use-github-status.ts
|
||||
use-task-pr.ts
|
||||
use-pr-feedback.ts
|
||||
use-review-watches.ts
|
||||
use-pr-watch.ts
|
||||
app/settings/general/github/
|
||||
page.tsx
|
||||
components/github/
|
||||
github-status.tsx
|
||||
review-watch-table.tsx
|
||||
review-watch-dialog.tsx
|
||||
pr-detail-panel.tsx # Session detail PR view (expandable panel)
|
||||
pr-session-topbar.tsx # Compact PR info in session topbar
|
||||
pr-task-icon.tsx # PR icon for task list
|
||||
refresh-reviews-button.tsx # Topbar manual pull button
|
||||
send-to-agent-dialog.tsx # Compose feedback to send to agent
|
||||
pr-stats.tsx # PR analytics cards + charts
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
```
|
||||
apps/backend/internal/events/types.go # Add GitHub event constants
|
||||
apps/backend/internal/orchestrator/service.go # Add githubService field + setter + push tracker
|
||||
apps/backend/internal/orchestrator/event_handlers_git.go # PR auto-detection on push
|
||||
apps/backend/cmd/kandev/services.go # Wire GitHub service
|
||||
apps/backend/cmd/kandev/types.go # Add GitHub to Services struct
|
||||
apps/backend/cmd/kandev/main.go # Register GitHub routes
|
||||
|
||||
apps/web/lib/state/store.ts # Add github slice
|
||||
apps/web/lib/state/hydration/merge-strategies.ts # Add github hydration
|
||||
apps/web/app/settings/general/ (layout or nav) # Add GitHub settings link
|
||||
apps/web/components/ (task card/row) # Add PR icon to task list
|
||||
apps/web/components/ (session detail panel) # Add PR detail panel
|
||||
```
|
||||
|
||||
## New file: `apps/backend/internal/orchestrator/event_handlers_github.go`
|
||||
|
||||
Orchestrator event handlers for GitHub events (handlePRFeedback, handleNewReviewPR).
|
||||
|
||||
## Key Patterns to Reuse
|
||||
|
||||
- **Provider pattern**: `Provide(cfg, log, ...) (*Service, cleanup, error)` — see `apps/backend/internal/secrets/provider.go`
|
||||
- **Event bus**: `bus.EventBus` publish/subscribe — see `apps/backend/internal/events/types.go`
|
||||
- **Setter injection**: `SetGitHubService()` — see `orchestrator/service.go` `SetWorkflowStepGetter()`
|
||||
- **Repository sub-interfaces**: Split interfaces in `apps/backend/internal/task/repository/interface.go`
|
||||
- **Task service Repos struct**: `taskservice.Repos{}` — see `apps/backend/cmd/kandev/services.go:38`
|
||||
- **Git event handling**: `handleGitStatusUpdate()` in `apps/backend/internal/orchestrator/event_handlers_git.go`
|
||||
- **Repository model provider fields**: `Provider`, `ProviderOwner`, `ProviderName` in `apps/backend/internal/task/models/models.go:285`
|
||||
- **Secrets store for PAT**: `apps/backend/internal/secrets/service.go` (Reveal method)
|
||||
- **Credential env provider**: `GITHUB_TOKEN` already listed in `apps/backend/internal/agent/credentials/env_provider.go:25`
|
||||
- **WS handler pattern**: See existing handlers in `apps/backend/internal/task/handlers/`
|
||||
- **Frontend store slices**: Domain pattern in `apps/web/lib/state/slices/`
|
||||
- **Frontend hooks**: Domain pattern in `apps/web/hooks/domains/`
|
||||
- **Settings page layout**: Existing pages at `apps/web/app/settings/general/{editors,secrets,notifications}/`
|
||||
- **Shadcn components**: Import from `@kandev/ui` — Table, Dialog, Button, Badge, etc.
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Milestone 1: GitHub Client (Backend)
|
||||
1. `models.go` - data models
|
||||
2. `client.go` - interface
|
||||
3. `gh_client.go` - gh CLI implementation
|
||||
4. `pat_client.go` - PAT fallback
|
||||
5. `factory.go` - client factory
|
||||
6. Unit tests for client JSON parsing
|
||||
|
||||
### Milestone 2: Persistence + Service (Backend)
|
||||
7. `store.go` - SQLite tables + CRUD
|
||||
8. `service.go` - business logic
|
||||
9. `provider.go` - DI provider
|
||||
10. Wire into `cmd/kandev/services.go`
|
||||
|
||||
### Milestone 3: API + Handlers (Backend)
|
||||
11. `controller.go` - HTTP endpoints
|
||||
12. `handlers.go` - WS handlers
|
||||
13. Add event types to `events/types.go`
|
||||
14. Wire routes in `cmd/kandev/main.go`
|
||||
|
||||
### Milestone 4: Poller + Orchestrator (Backend)
|
||||
15. `poller.go` - background polling
|
||||
16. Push detection in `event_handlers_git.go`
|
||||
17. `event_handlers_github.go` - orchestrator GitHub event handlers
|
||||
18. Wire GitHub service into orchestrator
|
||||
19. Integration tests
|
||||
|
||||
### Milestone 5: Frontend Core
|
||||
20. TypeScript types
|
||||
21. API client
|
||||
22. Store slice
|
||||
23. Domain hooks
|
||||
|
||||
### Milestone 6: Frontend UI
|
||||
24. Settings page (GitHub config + review watches)
|
||||
25. Task list PR icon
|
||||
26. Session detail PR panel (PR, comments, CI, approvals, "Send to Agent")
|
||||
27. Topbar refresh button
|
||||
|
||||
### Milestone 7: Stats
|
||||
28. `stats.go` - PR analytics queries (aggregations on `github_task_prs`)
|
||||
29. Stats HTTP endpoint (`/api/v1/github/stats`)
|
||||
30. Frontend stats component (`pr-stats.tsx`) with cards + charts
|
||||
31. Integrate stats into GitHub settings page or dashboard
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Backend unit tests**: Client JSON parsing, store CRUD, poller logic
|
||||
2. **Integration**: `make -C apps/backend test`
|
||||
3. **Lint/typecheck**: `make -C apps/backend lint` + `cd apps && pnpm --filter @kandev/web typecheck`
|
||||
4. **Manual**: Check `/api/v1/github/status`, create review watch, trigger manual pull
|
||||
5. **E2E**: Agent pushes → PR auto-detected → PR watch created → task shows PR icon → session detail shows PR panel → user reviews and sends feedback to agent
|
||||
@@ -5,28 +5,21 @@ set -euo pipefail
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 <version>"
|
||||
echo "Example: $0 0.1.2"
|
||||
echo "Example: $0 v0.1.2"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="$1"
|
||||
|
||||
LAUNCHER_VERSION="$(node -p "require('./apps/cli/package.json').version")"
|
||||
if [ "$LAUNCHER_VERSION" != "$VERSION" ]; then
|
||||
echo "Launcher version mismatch: apps/cli/package.json is $LAUNCHER_VERSION"
|
||||
echo "Run scripts/release/publish-launcher.sh $VERSION first."
|
||||
if git rev-parse "$VERSION" >/dev/null 2>&1; then
|
||||
echo "Tag $VERSION already exists."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git rev-parse "v$VERSION" >/dev/null 2>&1; then
|
||||
echo "Tag v$VERSION already exists."
|
||||
exit 1
|
||||
fi
|
||||
echo "Creating tag $VERSION..."
|
||||
git tag "$VERSION"
|
||||
|
||||
echo "Creating tag v$VERSION..."
|
||||
git tag "v$VERSION"
|
||||
|
||||
echo "Pushing tag v$VERSION..."
|
||||
git push origin "v$VERSION"
|
||||
echo "Pushing tag $VERSION..."
|
||||
git push origin "$VERSION"
|
||||
|
||||
echo "Done. GitHub Actions will build and upload runtime bundles."
|
||||
|
||||
Reference in New Issue
Block a user