Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,36 @@ Nothing is required. Everything is optional:

| Variable | Effect |
|----------|--------|
| `PRCHECK_THEME` | Color scheme: `auto` (default — detect the terminal background at startup, dark if unsure), `dark`, or `light`. |
| `PRCHECK_AGENT` | Which agent to start with: `claude` (default) or `cursor`. You can also switch in the app with `A`. |
| `PRCHECK_FOCUS` | Review aspects to emphasize, comma-separated, e.g. `security,performance,requirements,tests`. Empty means a balanced review. |
| `PRCHECK_NOTIFY=1` | Send a desktop notification when a review finishes. |
| `PRCHECK_WATCH` | Auto-refresh interval in minutes for Watch mode (default 5). |
| `PRCHECK_DEBUG=1` | Send subprocess (`gh`/agent) output to the log for troubleshooting. |
| `PRCHECK_CACHE_DIR` | Where snapshots, bookmarks, and history are written. Defaults to the OS cache dir (see below). |

**Config file (optional).** Settings can also live in a JSON file so you don't have to
export env vars. prcheck looks for it at (via `os.UserConfigDir()`):

- Linux: `~/.config/prcheck/config.json`
- macOS: `~/Library/Application Support/prcheck/config.json`
- Windows: `%AppData%\prcheck\config.json`

```json
{
"theme": "light"
}
```

Environment variables **override** the file (e.g. `PRCHECK_THEME=dark` wins over
`"theme": "light"`). A missing file is fine; a malformed one is ignored with a warning.
Currently only `theme` is read from the file — the other settings remain env-only for now.

**Color scheme.** `theme` (or `PRCHECK_THEME`) is `auto`, `dark`, or `light`. `auto` checks
the terminal's background color once at startup and picks the matching scheme, falling back
to `dark` if the terminal doesn't report one. The default `dark` scheme is tuned for dark
terminals; `light` is tuned for white/light backgrounds.

**Watch mode** (`w` key) auto-refreshes all tabs every `PRCHECK_WATCH` minutes (default 5).
When combined with `PRCHECK_NOTIFY=1`, it sends desktop notifications: "Review requested"
when a PR (re-)appears in the Review tab, "New PR" for new PRs elsewhere, and "Updated"
Expand Down
25 changes: 25 additions & 0 deletions cmd/prcheck/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"runtime"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"

"github.com/KamilSupera/github-pullrequests-checkecker/internal/claude"
"github.com/KamilSupera/github-pullrequests-checkecker/internal/config"
Expand Down Expand Up @@ -46,6 +47,15 @@ func run() error {
return err
}

// Resolve the color scheme once, up front. This must happen BEFORE
// tea.NewProgram takes over stdin in AltScreen mode: the "auto" path
// queries the terminal background, which would deadlock mid-run (see
// internal/tui/markdown.go). Freezing the result with
// SetHasDarkBackground also prevents lipgloss's own lazy query later.
dark := resolveDarkBackground(cfg.Theme)
lipgloss.SetHasDarkBackground(dark)
tui.SetTheme(dark)

if err := checkBinary("gh"); err != nil {
return err
}
Expand Down Expand Up @@ -103,6 +113,21 @@ func run() error {
return err
}

// resolveDarkBackground maps the configured theme to a dark/light choice.
// "dark"/"light" force the scheme; "auto" (and anything else) detects the
// terminal background, falling back to dark when the terminal doesn't
// answer (termenv reports a dark background for a non-TTY or no reply).
func resolveDarkBackground(theme string) bool {
switch theme {
case "light":
return false
case "dark":
return true
default: // "auto"
return lipgloss.HasDarkBackground()
}
}

type claudeAdapter struct{}

func (claudeAdapter) Invoke(ctx context.Context, prompt string) (*claude.Review, error) {
Expand Down
38 changes: 38 additions & 0 deletions internal/config/env.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"fmt"
"os"
"strconv"
"strings"
Expand All @@ -20,12 +21,25 @@ type Config struct {
// WatchMinutes is the auto-refresh interval for Watch mode, in
// minutes. Read from PRCHECK_WATCH; defaults to 5, clamped to >=1.
WatchMinutes int

// Theme selects the color scheme: "auto" (detect the terminal
// background at startup, dark on no reply), "dark", or "light".
// Read from the config file then PRCHECK_THEME (env overrides file);
// unknown/empty values fall back to "auto".
Theme string
}

// Load reads the runtime configuration from environment variables.
// All Jira access goes through the Atlassian MCP server reachable via
// `claude`, so no Jira credentials are required.
func Load() (*Config, error) {
// Config file first; env vars below override it. A malformed file is
// non-fatal — warn and carry on with defaults + environment.
fc, ferr := loadFile()
if ferr != nil {
fmt.Fprintln(os.Stderr, "prcheck: ignoring config file:", ferr)
}

cfg := &Config{
Debug: os.Getenv("PRCHECK_DEBUG") == "1",
Agent: strings.TrimSpace(os.Getenv("PRCHECK_AGENT")),
Expand All @@ -43,5 +57,29 @@ func Load() (*Config, error) {
cfg.WatchMinutes = n
}
}

// Theme: config file value, then PRCHECK_THEME override, then validate.
theme := ""
if fc.Theme != nil {
theme = *fc.Theme
}
if e := strings.TrimSpace(os.Getenv("PRCHECK_THEME")); e != "" {
theme = e
}
cfg.Theme = normalizeTheme(theme)

return cfg, nil
}

// normalizeTheme trims/lowercases the theme string and validates it.
// Unknown or empty values fall back to "auto".
func normalizeTheme(s string) string {
switch strings.ToLower(strings.TrimSpace(s)) {
case "dark":
return "dark"
case "light":
return "light"
default:
return "auto"
}
}
52 changes: 52 additions & 0 deletions internal/config/file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package config

import (
"encoding/json"
"os"
"path/filepath"
)

// fileConfig mirrors the on-disk JSON config file. Every field is a
// pointer so an omitted key stays distinguishable from an explicit zero
// value and never clobbers a setting supplied via the environment. Only
// Theme is wired up today; other fields can be added here as the
// PRCHECK_* env vars migrate into the file.
type fileConfig struct {
Theme *string `json:"theme"`
}

// configPath returns the path to prcheck's JSON config file, or "" when
// the user config dir cannot be determined. It uses os.UserConfigDir()
// — the config-dir analog of the cache dir used elsewhere — which is
// ~/.config/prcheck on Linux and ~/Library/Application Support/prcheck on
// macOS.
func configPath() string {
dir, err := os.UserConfigDir()
if err != nil {
return ""
}
return filepath.Join(dir, "prcheck", "config.json")
}

// loadFile reads and decodes the JSON config file. A missing file is not
// an error — it returns a zero fileConfig. Malformed JSON returns an
// error so the caller can warn the user; callers must treat it as
// non-fatal and fall back to defaults + environment.
func loadFile() (fileConfig, error) {
var fc fileConfig
path := configPath()
if path == "" {
return fc, nil
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return fc, nil
}
return fc, err
}
if err := json.Unmarshal(data, &fc); err != nil {
return fc, err
}
return fc, nil
}
121 changes: 121 additions & 0 deletions internal/config/file_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package config

import (
"os"
"path/filepath"
"testing"
)

// isolateConfigDir points os.UserConfigDir() at a fresh temp directory so
// the test never reads the developer's real ~/.config/prcheck/config.json.
// It sets both HOME (macOS) and XDG_CONFIG_HOME (Linux) to cover either
// platform's UserConfigDir resolution.
func isolateConfigDir(t *testing.T) {
t.Helper()
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, ".config"))
// Env vars must not leak in from the developer's shell.
t.Setenv("PRCHECK_THEME", "")
}

// writeConfig writes raw bytes to the resolved config path, creating the
// parent directory. Empty content means "no file".
func writeConfig(t *testing.T, content string) {
t.Helper()
if content == "" {
return
}
path := configPath()
if path == "" {
t.Fatal("configPath() returned empty")
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
}

func TestLoad_ThemeDefaultsAuto(t *testing.T) {
isolateConfigDir(t)

cfg, err := Load()
if err != nil {
t.Fatalf("Load() err: %v", err)
}
if cfg.Theme != "auto" {
t.Errorf("Theme = %q, want default \"auto\"", cfg.Theme)
}
}

func TestLoad_ThemeFromFile(t *testing.T) {
isolateConfigDir(t)
writeConfig(t, `{"theme": "light"}`)

cfg, err := Load()
if err != nil {
t.Fatalf("Load() err: %v", err)
}
if cfg.Theme != "light" {
t.Errorf("Theme = %q, want \"light\" from file", cfg.Theme)
}
}

func TestLoad_ThemeEnvOverridesFile(t *testing.T) {
isolateConfigDir(t)
writeConfig(t, `{"theme": "light"}`)
t.Setenv("PRCHECK_THEME", "dark")

cfg, err := Load()
if err != nil {
t.Fatalf("Load() err: %v", err)
}
if cfg.Theme != "dark" {
t.Errorf("Theme = %q, want env value \"dark\" to win over file", cfg.Theme)
}
}

func TestLoad_ThemeUnknownFallsBackAuto(t *testing.T) {
isolateConfigDir(t)
t.Setenv("PRCHECK_THEME", "psychedelic")

cfg, err := Load()
if err != nil {
t.Fatalf("Load() err: %v", err)
}
if cfg.Theme != "auto" {
t.Errorf("Theme = %q, want \"auto\" for unknown value", cfg.Theme)
}
}

func TestLoad_MalformedFileIsNonFatal(t *testing.T) {
isolateConfigDir(t)
writeConfig(t, `{ this is not valid json `)

cfg, err := Load()
if err != nil {
t.Fatalf("Load() should not fail on malformed file: %v", err)
}
if cfg.Theme != "auto" {
t.Errorf("Theme = %q, want \"auto\" when file is malformed", cfg.Theme)
}
}

func TestNormalizeTheme(t *testing.T) {
cases := map[string]string{
"dark": "dark",
"DARK": "dark",
" light": "light",
"Light ": "light",
"auto": "auto",
"": "auto",
"nonsense": "auto",
}
for in, want := range cases {
if got := normalizeTheme(in); got != want {
t.Errorf("normalizeTheme(%q) = %q, want %q", in, got, want)
}
}
}
33 changes: 22 additions & 11 deletions internal/tui/diffcolor.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,18 @@ import (
"github.com/charmbracelet/lipgloss"
)

// Diff line colors adapt to the terminal background: the Dark variants
// are the original ANSI-256 tones; the Light variants are darkened so
// they stay legible on a white background.
var (
diffAddLine = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) // green
diffDelLine = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) // red
diffHunkLine = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) // blue
diffFileLine = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214"))

chromaStyle = pickChromaStyle()
diffAddLine = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Dark: "42", Light: "#2e7d32"}) // green
diffDelLine = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Dark: "203", Light: "#c62828"}) // red
diffHunkLine = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Dark: "39", Light: "#1565c0"}) // blue
diffFileLine = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Dark: "214", Light: "#b25e00"})

// chromaStyle defaults to the dark syntax theme; SetTheme() swaps it
// to a light one when the terminal background is light.
chromaStyle = pickChromaStyle(true)
chromaFormatter chroma.Formatter
)

Expand All @@ -29,11 +34,17 @@ func init() {
}
}

// pickChromaStyle prefers a warm/amber-leaning style for the Blade
// Runner theme. Falls back through alternatives, then to a default
// style if none of the preferred names exist in the installed chroma.
func pickChromaStyle() *chroma.Style {
for _, name := range []string{"gruvbox", "tango", "solarized-dark", "monokai"} {
// pickChromaStyle returns the syntax-highlight style for the active
// theme. For dark it prefers a warm/amber-leaning style matching the
// Blade Runner palette; for light it prefers styles designed for a white
// background. Falls through the preference list, then to chroma's default
// if none of the preferred names exist in the installed version.
func pickChromaStyle(dark bool) *chroma.Style {
prefs := []string{"gruvbox", "tango", "solarized-dark", "monokai"}
if !dark {
prefs = []string{"github", "solarized-light", "friendly", "tango"}
}
for _, name := range prefs {
if s := styles.Get(name); s != nil && s.Name != "" {
return s
}
Expand Down
Loading