From 6b3ae2bbfa3b362e2081e81cbb68f3ef93b7905e Mon Sep 17 00:00:00 2001 From: Philipp Trentmann Date: Wed, 6 May 2026 18:37:09 +0200 Subject: [PATCH] feat: bootstrap ci, release-please pipeline, and dev workflow --- .githooks/commit-msg | 5 + .githooks/pre-commit | 5 + .github/workflows/ci.yml | 78 +++++ .github/workflows/commitlint.yml | 45 +++ .github/workflows/release-please.yml | 62 ++++ .gitignore | 33 ++ .release-please-manifest.json | 3 + Makefile | 51 +++ README.md | 232 +++++++++++++ cmd/root.go | 93 +++++ go.mod | 34 ++ go.sum | 56 +++ internal/config/config.go | 59 ++++ internal/config/load.go | 60 ++++ internal/config/save.go | 59 ++++ internal/config/validate.go | 42 +++ internal/github/derive.go | 238 +++++++++++++ internal/github/derive_test.go | 133 ++++++++ internal/github/fetch.go | 146 ++++++++ internal/github/graphql.go | 35 ++ internal/github/query.graphql | 81 +++++ internal/github/repo.go | 53 +++ internal/github/runner.go | 52 +++ internal/github/types.go | 252 ++++++++++++++ internal/ui/app.go | 213 ++++++++++++ internal/ui/commands.go | 77 +++++ internal/ui/components/banner.go | 71 ++++ internal/ui/components/confirm.go | 18 + internal/ui/components/countdown.go | 36 ++ internal/ui/components/spinner.go | 41 +++ internal/ui/components/toast.go | 67 ++++ internal/ui/dashboard.go | 494 +++++++++++++++++++++++++++ internal/ui/helpers.go | 63 ++++ internal/ui/keys.go | 35 ++ internal/ui/menu.go | 68 ++++ internal/ui/messages.go | 32 ++ internal/ui/repos.go | 190 +++++++++++ internal/ui/settings.go | 180 ++++++++++ internal/ui/theme/gradient.go | 75 ++++ internal/ui/theme/palette.go | 34 ++ internal/ui/theme/styles.go | 90 +++++ main.go | 37 ++ release-please-config.json | 34 ++ scripts/check-commit-msg.sh | 54 +++ scripts/check-go-fmt.sh | 64 ++++ 45 files changed, 3880 insertions(+) create mode 100755 .githooks/commit-msg create mode 100755 .githooks/pre-commit create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/commitlint.yml create mode 100644 .github/workflows/release-please.yml create mode 100644 .gitignore create mode 100644 .release-please-manifest.json create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/root.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/config/load.go create mode 100644 internal/config/save.go create mode 100644 internal/config/validate.go create mode 100644 internal/github/derive.go create mode 100644 internal/github/derive_test.go create mode 100644 internal/github/fetch.go create mode 100644 internal/github/graphql.go create mode 100644 internal/github/query.graphql create mode 100644 internal/github/repo.go create mode 100644 internal/github/runner.go create mode 100644 internal/github/types.go create mode 100644 internal/ui/app.go create mode 100644 internal/ui/commands.go create mode 100644 internal/ui/components/banner.go create mode 100644 internal/ui/components/confirm.go create mode 100644 internal/ui/components/countdown.go create mode 100644 internal/ui/components/spinner.go create mode 100644 internal/ui/components/toast.go create mode 100644 internal/ui/dashboard.go create mode 100644 internal/ui/helpers.go create mode 100644 internal/ui/keys.go create mode 100644 internal/ui/menu.go create mode 100644 internal/ui/messages.go create mode 100644 internal/ui/repos.go create mode 100644 internal/ui/settings.go create mode 100644 internal/ui/theme/gradient.go create mode 100644 internal/ui/theme/palette.go create mode 100644 internal/ui/theme/styles.go create mode 100644 main.go create mode 100644 release-please-config.json create mode 100755 scripts/check-commit-msg.sh create mode 100755 scripts/check-go-fmt.sh diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..26a8d29 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Thin wrapper: defer to the shared validator so local and CI use identical rules. +set -euo pipefail +repo_root="$(git rev-parse --show-toplevel)" +exec "${repo_root}/scripts/check-commit-msg.sh" "$1" diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..054a008 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Thin wrapper: defer to the shared validator so local and CI use identical rules. +set -euo pipefail +repo_root="$(git rev-parse --show-toplevel)" +exec "${repo_root}/scripts/check-go-fmt.sh" staged diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c1a03c6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,78 @@ +name: CI + +on: + pull_request: + branches: [ develop, main ] + push: + branches: [ develop, main ] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25" + cache: true + + - name: Download modules + run: go mod download + + - name: Verify modules + run: go mod verify + + - name: gofmt + run: | + set -euo pipefail + unformatted="$(gofmt -l .)" + if [[ -n "$unformatted" ]]; then + echo "::error::Files need gofmt:" + echo "$unformatted" + exit 1 + fi + + - name: go vet + run: go vet ./... + + test: + name: Build & test (${{ matrix.label }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + label: Linux + - os: macos-latest + label: macOS + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25" + cache: true + + - name: Download modules + run: go mod download + + - name: go build + run: go build ./... + + - name: go test + run: go test -race -count=1 ./... diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml new file mode 100644 index 0000000..8a6fdb3 --- /dev/null +++ b/.github/workflows/commitlint.yml @@ -0,0 +1,45 @@ +name: commitlint + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + +jobs: + conventional-commits: + name: Validate commit messages + runs-on: ubuntu-latest + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - name: Validate every commit in the PR range + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + chmod +x scripts/check-commit-msg.sh + + fail=0 + tmp="$(mktemp)" + trap 'rm -f "$tmp"' EXIT + + # Walk every commit introduced by this PR. + while IFS= read -r sha; do + git log -1 --format=%B "$sha" > "$tmp" + if ! scripts/check-commit-msg.sh "$tmp"; then + echo "::error::commit $sha has an invalid message header" + fail=1 + fi + done < <(git rev-list --no-merges "$BASE_SHA..$HEAD_SHA") + + if [[ $fail -ne 0 ]]; then + echo "::error::One or more commits violate Conventional Commits 1.0.0." + echo "Fix locally with: git rebase -i $BASE_SHA (reword the offending commits)" + exit 1 + fi + + echo "All commit messages conform to Conventional Commits 1.0.0." diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..80274eb --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,62 @@ +name: Release Please + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + issues: write + repository-projects: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: google-github-actions/release-please-action@v4 + id: release + with: + token: ${{ secrets.PAT_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + target-branch: main + + # Build and upload binaries only when a release is created. + - uses: actions/checkout@v4 + if: ${{ steps.release.outputs.release_created }} + + - name: Set up Go + if: ${{ steps.release.outputs.release_created }} + uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - name: Get dependencies + if: ${{ steps.release.outputs.release_created }} + run: go mod download + + - name: Build cross-platform binaries + if: ${{ steps.release.outputs.release_created }} + env: + CGO_ENABLED: 0 + run: | + mkdir -p dist + + VERSION="${{ steps.release.outputs.tag_name }}" + BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + LDFLAGS="-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME}" + + GOOS=linux GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o dist/github-butler-linux-amd64 . + GOOS=linux GOARCH=arm64 go build -ldflags="${LDFLAGS}" -o dist/github-butler-linux-arm64 . + GOOS=darwin GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o dist/github-butler-darwin-amd64 . + GOOS=darwin GOARCH=arm64 go build -ldflags="${LDFLAGS}" -o dist/github-butler-darwin-arm64 . + GOOS=windows GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o dist/github-butler-windows-amd64.exe . + + - name: Upload Release Assets + if: ${{ steps.release.outputs.release_created }} + env: + GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} + run: | + gh release upload ${{ steps.release.outputs.tag_name }} dist/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f202bc0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Binaries +github-butler +/bin/ +/dist/ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test / coverage +*.test +*.out +coverage.txt +coverage.html + +# Go build cache / workspace files +/vendor/ +go.work +go.work.sum + +# IDE / editor +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# macOS +.DS_Store + +# Local config (user config lives in ~/.config/github-butler/) +config.local.yaml diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..466df71 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e3d9170 --- /dev/null +++ b/Makefile @@ -0,0 +1,51 @@ +.PHONY: setup build build-all release clean test fmt vet tidy + +BINARY_NAME := github-butler +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +BUILD_TIME := $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +LDFLAGS := -s -w -X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME) + +# `setup` wires the in-repo git hooks. It is idempotent and is a prerequisite +# of `build` and `test`, so any common dev command re-asserts the hook config. +setup: + @git config core.hooksPath .githooks + @chmod +x .githooks/* scripts/*.sh 2>/dev/null || true + @echo "hooks active: core.hooksPath = $$(git config core.hooksPath)" + +build: setup + @echo "Building $(BINARY_NAME) $(VERSION)..." + @go build -ldflags="$(LDFLAGS)" -o $(BINARY_NAME) . + @echo "Built ./$(BINARY_NAME)" + +# Cross-compile to dist/ for the 5 release targets. CGO is off so the +# resulting binaries are fully static and don't depend on libc on the +# host they're installed to. +build-all: + @echo "Building $(BINARY_NAME) $(VERSION) for all platforms..." + @mkdir -p dist + @CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o dist/$(BINARY_NAME)-linux-amd64 . + @CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o dist/$(BINARY_NAME)-linux-arm64 . + @CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o dist/$(BINARY_NAME)-darwin-amd64 . + @CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o dist/$(BINARY_NAME)-darwin-arm64 . + @CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o dist/$(BINARY_NAME)-windows-amd64.exe . + @echo "Built all platform binaries in dist/" + +release: clean build-all + @echo "Release artifacts ready in dist/:" + @ls -la dist/ + +clean: + @rm -rf dist/ $(BINARY_NAME) + @echo "Cleaned build artifacts" + +test: setup + go test ./... + +fmt: + go fmt ./... + +vet: + go vet ./... + +tidy: + go mod tidy diff --git a/README.md b/README.md new file mode 100644 index 0000000..350cea3 --- /dev/null +++ b/README.md @@ -0,0 +1,232 @@ +# github-butler + +A neon-themed terminal dashboard that polls GitHub via the local `gh` CLI and shows your open PRs across multiple repositories at a glance — including CI status, required reviewers, unresolved review threads, draft/stale flags, and more. + +## Features + +- Live-refreshing TUI (Bubble Tea) with a neon truecolor palette and gradients +- Polls every N seconds (default 20s, configurable in-app) +- Tracks **only PRs you authored** across a configurable list of repositories +- Shows per-PR: + - CI status with failing check names in the detail pane + - Overall review decision (`REVIEW` column) plus a dedicated **`REQ`** column for CODEOWNERS-required reviewers only, so you can tell at a glance what's actually gating the merge + - Details pane splits reviewers into **REQUIRED (CODEOWNERS)** and **OPTIONAL** sub-sections + - **`MERGE`** column in the details pane explaining why a PR can't be merged (conflicts, behind base, draft, changes requested, missing required approvals, failing checks, pending checks, branch protection, …) with severity-appropriate colors + - Unresolved review-thread count + - `[DRAFT]` chip for draft PRs (still listed, not filtered out) + - `[STALE]` chip for PRs created more than 4 weeks ago +- In-app menu for **managing repositories** (add/remove) and **settings** (poll interval) +- Config persisted as YAML; atomic saves so a crash can't corrupt the file +- Open a highlighted PR in your browser with one keystroke + +## Requirements + +- Go 1.25+ to build +- [`gh`](https://cli.github.com/) CLI installed and authenticated (`gh auth login`) + +## Install + +### From a release + +Pre-built binaries for Linux, macOS, and Windows are published on the [Releases](https://github.com/bluegardenproject/github-butler/releases) page. Grab the asset for your platform, mark it executable, and drop it on `$PATH`: + +```bash +curl -L -o github-butler https://github.com/bluegardenproject/github-butler/releases/latest/download/github-butler-darwin-arm64 +chmod +x github-butler +mv github-butler /usr/local/bin/ +``` + +(Replace `darwin-arm64` with `linux-amd64`, `linux-arm64`, `darwin-amd64`, or `windows-amd64.exe` as appropriate.) + +### From source + +Clone the repo and build a local binary: + +```bash +git clone https://github.com/bluegardenproject/github-butler.git +cd github-butler +make build +``` + +Or install straight into `$GOBIN` (usually `~/go/bin`): + +```bash +go install github.com/bluegardenproject/github-butler@latest +``` + +Verify with `github-butler --version`. + +## Usage + +Make sure `gh` is authenticated first (the app shells out to it for every GitHub call): + +```bash +gh auth login +gh auth status +``` + +Then run the binary: + +```bash +github-butler +``` + +### First launch + +On the very first run there's no config file yet, so the dashboard opens empty. Add repos from inside the app: + +1. Press `m` to open the main menu. +2. Select **Repositories** and press `Enter`. +3. Press `a`, paste a repo reference (`owner/repo`, an HTTPS URL, or an SSH URL), and press `Enter`. +4. Repeat for as many repos as you want to track, then press `Esc` to return to the dashboard. + +The app validates each repo via `gh api repos/:owner/:repo` before saving, and writes the updated config to `~/.config/github-butler/config.yaml` atomically. + +### Examples + +Run with a custom config path: + +```bash +github-butler --config ./my-config.yaml +``` + +Run without any color styling (useful for screenshots, CI logs, or terminals without truecolor support): + +```bash +github-butler --no-color +``` + +`NO_COLOR=1` in the environment has the same effect. + +### Quitting + +Press `q` or `Ctrl+C` from any screen to exit cleanly. + +## Config + +Stored at `~/.config/github-butler/config.yaml` (override with `--config`). The app will create it on first save. + +```yaml +repos: + - owner/repo-a + - owner/repo-b +poll_interval_seconds: 20 +group_by_repo: false +``` + +### Available settings + +| Key | Type | Default | Description | +| ----------------------- | ---- | ------- | -------------------------------------------------------------------------------------------- | +| `repos` | list | `[]` | List of `owner/repo` slugs to track | +| `poll_interval_seconds` | int | `20` | How often the app polls GitHub (min `2`, max `3600`) | +| `group_by_repo` | bool | `false` | When `true`, PRs are grouped under per-repo section headers instead of one flat updated list | + +All three are editable from inside the app (`m` → **Settings** / **Repositories**), so you rarely need to hand-edit the YAML — but doing so works too. + +On first launch with no config, the app opens to an empty dashboard; press `m` → Repositories → `a` to add one. + +## Key bindings + +### Dashboard + +| Key | Action | +| ----------- | --------------------------- | +| `↑` / `k` | Move selection up | +| `↓` / `j` | Move selection down | +| `o`, Enter | Open selected PR in browser | +| `r` | Refresh now | +| `m` | Open main menu | +| `q`, Ctrl+C | Quit | + +### Menu / Repositories / Settings + +| Key | Action | +| --------- | ------------------------------------------------------------------------- | +| `↑` / `k` | Up | +| `↓` / `j` | Down | +| Enter | Select | +| `a` | Add repository (Repositories view) | +| `d` | Delete highlighted repository | +| `y` / `n` | Confirm / cancel delete | +| Esc | Back one level (Repos/Settings → Menu, Menu → Dashboard) | +| `m` | Jump back to the dashboard from anywhere (except while typing in a field) | + +When adding a repo you can paste any of: + +- `owner/repo` +- `https://github.com/owner/repo` (with or without `.git`) +- `git@github.com:owner/repo.git` + +The app validates the repo exists (and you can access it) via `gh api repos/:owner/:repo` before saving. + +## Flags + +``` +--config PATH path to config file (default: ~/.config/github-butler/config.yaml) +--no-color disable all color output (also respects the NO_COLOR env var) +--version print version, build time, Go runtime, platform, and exit +``` + +## Project layout + +``` +main.go # entrypoint; declares Version / BuildTime ldflag targets +cmd/root.go # flag parsing + wiring, including --version +internal/ + config/ # YAML load/save/validate, defaults + github/ # gh CLI wrapper, GraphQL query, pure derivation logic + ui/ + app.go # root Bubble Tea model + screen routing + messages.go # shared tea.Msg types + commands.go # tea.Cmd factories (fetch, save, open URL, validate) + keys.go # central key bindings + helpers.go # text padding / truncation + dashboard.go # dashboard screen + detail pane + menu.go # main menu + repos.go # repo manager + add/remove flows + settings.go # settings list + interval editor + theme/ # neon palette, styles, gradient helper + components/ # small reusable widgets (banner, countdown, toast, confirm) +``` + +## Development + +```bash +make setup # wire up in-repo git hooks (run once after clone) +make build # → ./github-butler +make test # go test ./... +``` + +Commits must follow [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) and Go files must pass `gofmt`. Both rules are enforced locally by the hooks in [`.githooks/`](.githooks/) (wired up by `make setup`) and re-checked in CI. + +### Conventional commit types + +Allowed types: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`. Scope is optional and lower-case (e.g. `feat(ui): …`). Append `!` after the type/scope to mark a breaking change. + +``` +feat(ui): add per-PR merge-blocker column + +fix(config): tolerate missing poll_interval_seconds + +refactor(github)!: rename Client.List → Client.OpenForUser +``` + +## Releases + +Releases are driven by [Release Please](https://github.com/googleapis/release-please) on `main`: + +1. Every push to `main` runs the [release-please workflow](.github/workflows/release-please.yml). It opens (or updates) a release PR that aggregates all unreleased Conventional Commits into a `CHANGELOG.md` entry and bumps the version in `main.go` (via the `extra-files` entry in [`release-please-config.json`](release-please-config.json)) and `.release-please-manifest.json`. +2. Merging the release PR creates a Git tag (e.g. `v0.2.0`) and a GitHub Release. The same workflow then cross-compiles the five release binaries (`linux-amd64`, `linux-arm64`, `darwin-amd64`, `darwin-arm64`, `windows-amd64.exe`) with `-ldflags "-X main.Version=… -X main.BuildTime=…"` and uploads them as release assets. + +Build artifacts can be reproduced locally with: + +```bash +make build-all +``` + +The workflow uses a `PAT_TOKEN` repository secret with `repo` and `workflow` scope so release-please can push tags and create releases. + +## License + +MIT diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..9bee90c --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,93 @@ +// Package cmd wires the config, GitHub client, and UI into a single +// runnable program. Keeping this separate from main.go makes it trivial +// to add subcommands later without touching the UI layer. +package cmd + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "os/exec" + "runtime" + + "github.com/bluegardenproject/github-butler/internal/config" + "github.com/bluegardenproject/github-butler/internal/github" + "github.com/bluegardenproject/github-butler/internal/ui" + tea "github.com/charmbracelet/bubbletea" +) + +var ( + version = "dev" + buildTime = "unknown" +) + +// SetVersion stores the build-time version metadata so the `--version` +// flag and any future `version` subcommand can render it consistently. +// Called from main() before Run(). +func SetVersion(v, bt string) { + if v != "" { + version = v + } + if bt != "" { + buildTime = bt + } +} + +// Run parses flags, loads config, and starts the Bubble Tea program. +func Run(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("github-butler", flag.ContinueOnError) + cfgPath := fs.String("config", "", "path to config file (default: ~/.config/github-butler/config.yaml)") + noColor := fs.Bool("no-color", false, "disable all color output") + showVersion := fs.Bool("version", false, "print version information and exit") + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + + if *showVersion { + fmt.Printf("github-butler %s\n", version) + fmt.Printf(" Built: %s\n", buildTime) + fmt.Printf(" Go: %s\n", runtime.Version()) + fmt.Printf(" Platform: %s/%s\n", runtime.GOOS, runtime.GOARCH) + return nil + } + + if *noColor { + _ = os.Setenv("NO_COLOR", "1") + } + + if _, err := exec.LookPath("gh"); err != nil { + return fmt.Errorf("the GitHub CLI (`gh`) was not found in PATH; install it and run `gh auth login` first") + } + + path := *cfgPath + if path == "" { + var err error + path, err = config.DefaultPath() + if err != nil { + return fmt.Errorf("resolving default config path: %w", err) + } + } + + cfg, err := config.Load(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + cfg = config.Default() + cfg.Path = path + } else { + return fmt.Errorf("loading config: %w", err) + } + } + cfg.Path = path + + client := github.NewClient() + model := ui.NewModel(cfg, client) + + p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithContext(ctx)) + _, err = p.Run() + return err +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5820bfe --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module github.com/bluegardenproject/github-butler + +go 1.25.0 + +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ab78717 --- /dev/null +++ b/go.sum @@ -0,0 +1,56 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..4e67623 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,59 @@ +// Package config owns the on-disk YAML configuration: defaults, loading, +// atomic saving, and validation. The UI and GitHub packages only see an +// already-validated Config value. +package config + +import ( + "os" + "path/filepath" + "time" +) + +const ( + DefaultPollInterval = 20 * time.Second + MinPollIntervalSeconds = 2 + MaxPollIntervalSeconds = 3600 +) + +// Config is the user-facing configuration persisted as YAML. +type Config struct { + Repos []string `yaml:"repos"` + PollIntervalSeconds int `yaml:"poll_interval_seconds"` + GroupByRepo bool `yaml:"group_by_repo"` + + // Path is the file this config was loaded from (or should be saved to). + // Not persisted to YAML. + Path string `yaml:"-"` +} + +// PollInterval returns the poll interval as a time.Duration, falling back +// to the default if the stored value is zero or outside the allowed range. +func (c Config) PollInterval() time.Duration { + s := c.PollIntervalSeconds + if s < MinPollIntervalSeconds || s > MaxPollIntervalSeconds { + return DefaultPollInterval + } + return time.Duration(s) * time.Second +} + +// Default returns a Config with sensible defaults but no repos. +func Default() Config { + return Config{ + Repos: []string{}, + PollIntervalSeconds: int(DefaultPollInterval / time.Second), + } +} + +// DefaultPath returns the standard XDG-ish config file path: +// $XDG_CONFIG_HOME/github-butler/config.yaml, falling back to +// $HOME/.config/github-butler/config.yaml. +func DefaultPath() (string, error) { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "github-butler", "config.yaml"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".config", "github-butler", "config.yaml"), nil +} diff --git a/internal/config/load.go b/internal/config/load.go new file mode 100644 index 0000000..72a5314 --- /dev/null +++ b/internal/config/load.go @@ -0,0 +1,60 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// Load reads the YAML file at path, applies defaults for missing fields, +// and validates the result. If the file does not exist, a default Config +// is returned (with the path set) so the user can populate it via the UI. +func Load(path string) (Config, error) { + path, err := expandHome(path) + if err != nil { + return Config{}, err + } + + cfg := Default() + cfg.Path = path + + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return cfg, nil + } + return Config{}, fmt.Errorf("reading %s: %w", path, err) + } + + if err := yaml.Unmarshal(data, &cfg); err != nil { + return Config{}, fmt.Errorf("parsing %s: %w", path, err) + } + cfg.Path = path + + if cfg.PollIntervalSeconds == 0 { + cfg.PollIntervalSeconds = int(DefaultPollInterval / 1e9) + } + if cfg.Repos == nil { + cfg.Repos = []string{} + } + + if err := Validate(cfg); err != nil { + return Config{}, err + } + return cfg, nil +} + +func expandHome(path string) (string, error) { + if !strings.HasPrefix(path, "~") { + return path, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, strings.TrimPrefix(path, "~")), nil +} diff --git a/internal/config/save.go b/internal/config/save.go new file mode 100644 index 0000000..ba3519d --- /dev/null +++ b/internal/config/save.go @@ -0,0 +1,59 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// Save writes the config atomically to cfg.Path (temp file + rename), creating +// the parent directory if necessary. Returns an error if the config is invalid. +func Save(cfg Config) error { + if err := Validate(cfg); err != nil { + return err + } + if cfg.Path == "" { + return fmt.Errorf("config has no path set") + } + + dir := filepath.Dir(cfg.Path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("creating %s: %w", dir, err) + } + + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("marshaling config: %w", err) + } + + tmp, err := os.CreateTemp(dir, ".config-*.yaml.tmp") + if err != nil { + return fmt.Errorf("creating temp file: %w", err) + } + tmpPath := tmp.Name() + + cleanup := func() { _ = os.Remove(tmpPath) } + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + cleanup() + return fmt.Errorf("writing temp file: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + cleanup() + return fmt.Errorf("syncing temp file: %w", err) + } + if err := tmp.Close(); err != nil { + cleanup() + return fmt.Errorf("closing temp file: %w", err) + } + + if err := os.Rename(tmpPath, cfg.Path); err != nil { + cleanup() + return fmt.Errorf("renaming to %s: %w", cfg.Path, err) + } + return nil +} diff --git a/internal/config/validate.go b/internal/config/validate.go new file mode 100644 index 0000000..3ef58fa --- /dev/null +++ b/internal/config/validate.go @@ -0,0 +1,42 @@ +package config + +import ( + "fmt" + "regexp" + "strings" +) + +// repoSlugPattern accepts GitHub-style owner/name pairs. Owners and repos +// may contain letters, digits, hyphens, underscores, and dots; both segments +// must be 1..100 characters. +var repoSlugPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,99}/[A-Za-z0-9._-]{1,100}$`) + +// Validate checks the config for obvious mistakes. Repos are checked for +// the "owner/repo" slug format; poll interval is bounds-checked. +func Validate(cfg Config) error { + if cfg.PollIntervalSeconds != 0 { + if cfg.PollIntervalSeconds < MinPollIntervalSeconds || cfg.PollIntervalSeconds > MaxPollIntervalSeconds { + return fmt.Errorf("poll_interval_seconds must be between %d and %d (got %d)", + MinPollIntervalSeconds, MaxPollIntervalSeconds, cfg.PollIntervalSeconds) + } + } + seen := make(map[string]struct{}, len(cfg.Repos)) + for _, r := range cfg.Repos { + r = strings.TrimSpace(r) + if !repoSlugPattern.MatchString(r) { + return fmt.Errorf("invalid repo %q (expected owner/name)", r) + } + key := strings.ToLower(r) + if _, dup := seen[key]; dup { + return fmt.Errorf("duplicate repo %q", r) + } + seen[key] = struct{}{} + } + return nil +} + +// IsValidRepoSlug reports whether s is a valid owner/repo slug. +// Exposed so the UI can validate manual input before mutating the config. +func IsValidRepoSlug(s string) bool { + return repoSlugPattern.MatchString(strings.TrimSpace(s)) +} diff --git a/internal/github/derive.go b/internal/github/derive.go new file mode 100644 index 0000000..4faa42a --- /dev/null +++ b/internal/github/derive.go @@ -0,0 +1,238 @@ +package github + +import "sort" + +// toPR converts a raw GraphQL node into the PR type exposed to the rest +// of the app. All derivation (failing checks, unresolved count, required +// reviews merge) lives here as pure logic so it can be unit-tested +// without hitting the network. +func toPR(n rawNode) PR { + p := PR{ + Repo: n.Repository.NameWithOwner, + Number: n.Number, + Title: n.Title, + URL: n.URL, + HeadRef: n.HeadRefName, + CreatedAt: n.CreatedAt, + UpdatedAt: n.UpdatedAt, + IsDraft: n.IsDraft, + ReviewDecision: n.ReviewDecision, + Mergeable: n.Mergeable, + MergeStateStatus: n.MergeStateStatus, + } + + p.Checks = extractChecks(n) + for _, c := range p.Checks { + switch c.State { + case CheckStateFailure: + p.FailingChecks = append(p.FailingChecks, c) + case CheckStatePending: + p.PendingChecks++ + } + } + p.TotalChecks = len(p.Checks) + + for _, t := range n.ReviewThreads.Nodes { + if !t.IsResolved { + p.UnresolvedCount++ + } + } + + p.RequiredReviews = mergeRequiredReviews(n.ReviewRequests.Nodes, n.LatestReviews.Nodes) + return p +} + +// extractChecks normalizes CheckRun and StatusContext nodes into a single +// []Check slice. +func extractChecks(n rawNode) []Check { + if len(n.Commits.Nodes) == 0 || n.Commits.Nodes[0].Commit.StatusCheckRollup == nil { + return nil + } + ctxs := n.Commits.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes + out := make([]Check, 0, len(ctxs)) + for _, c := range ctxs { + switch c.Typename { + case "CheckRun": + out = append(out, Check{ + Name: c.Name, + State: mapCheckRunState(c.Status, c.Conclusion), + DetailsURL: c.DetailsURL, + }) + case "StatusContext": + out = append(out, Check{ + Name: c.Context, + State: mapStatusContextState(c.State), + DetailsURL: c.TargetURL, + }) + } + } + return out +} + +// mapCheckRunState converts GitHub's CheckRun status+conclusion pair into +// our simplified CheckState. A CheckRun is either in-progress (status != +// COMPLETED) or completed with a conclusion. +func mapCheckRunState(status, conclusion string) CheckState { + if status != "COMPLETED" && status != "" { + return CheckStatePending + } + switch conclusion { + case "SUCCESS": + return CheckStateSuccess + case "FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE": + return CheckStateFailure + case "NEUTRAL": + return CheckStateNeutral + case "SKIPPED": + return CheckStateSkipped + case "": + return CheckStatePending + default: + return CheckStateUnknown + } +} + +// mapStatusContextState converts a legacy commit-status state ("SUCCESS", +// "ERROR", "FAILURE", "PENDING", "EXPECTED") into our CheckState. +func mapStatusContextState(state string) CheckState { + switch state { + case "SUCCESS": + return CheckStateSuccess + case "ERROR", "FAILURE": + return CheckStateFailure + case "PENDING", "EXPECTED": + return CheckStatePending + default: + return CheckStateUnknown + } +} + +// mergeRequiredReviews combines still-pending review requests with the +// latest-per-reviewer review states. See plan "Required reviews derivation" +// for the rules this implements. +func mergeRequiredReviews(requests []rawReviewRequest, reviews []rawLatestReview) []RequiredReview { + byKey := make(map[string]*RequiredReview) + + addPending := func(key, name string, kind ReviewerKind, codeOwner bool) { + if key == "" { + return + } + if existing, ok := byKey[key]; ok { + if codeOwner { + existing.RequiredByCodeOwner = true + } + return + } + byKey[key] = &RequiredReview{ + Name: name, + Kind: kind, + RequiredByCodeOwner: codeOwner, + State: ReviewStatePending, + } + } + + for _, r := range requests { + switch r.RequestedReviewer.Typename { + case "User": + login := r.RequestedReviewer.Login + if login == "" { + continue + } + addPending("user:"+login, "@"+login, ReviewerKindUser, r.AsCodeOwner) + case "Team": + slug := r.RequestedReviewer.CombinedSlug + if slug == "" { + continue + } + addPending("team:"+slug, "@"+slug, ReviewerKindTeam, r.AsCodeOwner) + } + } + + upsertReview := func(key, name string, kind ReviewerKind, state ReviewState, approvedBy string) { + if key == "" { + return + } + if existing, ok := byKey[key]; ok { + if stateRank(state) > stateRank(existing.State) { + existing.State = state + if approvedBy != "" && existing.ApprovedByLogin == "" { + existing.ApprovedByLogin = approvedBy + } + } + return + } + byKey[key] = &RequiredReview{ + Name: name, + Kind: kind, + State: state, + ApprovedByLogin: approvedBy, + } + } + + for _, rv := range reviews { + login := rv.Author.Login + state := reviewStateFromString(rv.State) + if state == "" || login == "" { + continue + } + upsertReview("user:"+login, "@"+login, ReviewerKindUser, state, "") + + for _, team := range rv.OnBehalfOf.Nodes { + slug := team.CombinedSlug + if slug == "" { + continue + } + upsertReview("team:"+slug, "@"+slug, ReviewerKindTeam, state, login) + } + } + + out := make([]RequiredReview, 0, len(byKey)) + for _, v := range byKey { + out = append(out, *v) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].RequiredByCodeOwner != out[j].RequiredByCodeOwner { + return out[i].RequiredByCodeOwner + } + if out[i].Kind != out[j].Kind { + return out[i].Kind == ReviewerKindTeam + } + return out[i].Name < out[j].Name + }) + return out +} + +func reviewStateFromString(s string) ReviewState { + switch s { + case "APPROVED": + return ReviewStateApproved + case "CHANGES_REQUESTED": + return ReviewStateChangesRequested + case "COMMENTED": + return ReviewStateCommented + case "DISMISSED": + return ReviewStateDismissed + case "PENDING": + return ReviewStatePending + } + return "" +} + +// stateRank defines which review state "wins" when a reviewer appears in +// multiple places. Approved / changes-requested are authoritative; a later +// COMMENTED does not overwrite them. +func stateRank(s ReviewState) int { + switch s { + case ReviewStateChangesRequested: + return 4 + case ReviewStateApproved: + return 3 + case ReviewStateCommented: + return 2 + case ReviewStateDismissed: + return 1 + case ReviewStatePending: + return 0 + } + return -1 +} diff --git a/internal/github/derive_test.go b/internal/github/derive_test.go new file mode 100644 index 0000000..3d974b1 --- /dev/null +++ b/internal/github/derive_test.go @@ -0,0 +1,133 @@ +package github + +import ( + "testing" +) + +func TestMergeRequiredReviews_CodeOwnerPendingTeam(t *testing.T) { + requests := []rawReviewRequest{ + {AsCodeOwner: true, RequestedReviewer: struct { + Typename string `json:"__typename"` + Login string `json:"login"` + Name string `json:"name"` + CombinedSlug string `json:"combinedSlug"` + }{Typename: "Team", CombinedSlug: "acme/backend"}}, + } + got := mergeRequiredReviews(requests, nil) + if len(got) != 1 { + t.Fatalf("want 1 review, got %d", len(got)) + } + r := got[0] + if r.Name != "@acme/backend" || r.Kind != ReviewerKindTeam || !r.RequiredByCodeOwner || r.State != ReviewStatePending { + t.Fatalf("unexpected: %#v", r) + } +} + +func TestMergeRequiredReviews_ApprovedOnBehalfOfTeam(t *testing.T) { + reviews := []rawLatestReview{ + { + State: "APPROVED", + Author: struct { + Login string `json:"login"` + }{Login: "alice"}, + OnBehalfOf: struct { + Nodes []struct { + Name string `json:"name"` + CombinedSlug string `json:"combinedSlug"` + } `json:"nodes"` + }{Nodes: []struct { + Name string `json:"name"` + CombinedSlug string `json:"combinedSlug"` + }{{CombinedSlug: "acme/frontend"}}}, + }, + } + got := mergeRequiredReviews(nil, reviews) + if len(got) != 2 { + t.Fatalf("want 2 entries (user + team), got %d", len(got)) + } + + var team *RequiredReview + for i := range got { + if got[i].Kind == ReviewerKindTeam { + team = &got[i] + } + } + if team == nil { + t.Fatal("team entry missing") + } + if team.State != ReviewStateApproved { + t.Fatalf("team state = %s, want APPROVED", team.State) + } + if team.ApprovedByLogin != "alice" { + t.Fatalf("approved-by = %q, want alice", team.ApprovedByLogin) + } +} + +func TestMergeRequiredReviews_ChangesRequestedWins(t *testing.T) { + reviews := []rawLatestReview{ + {State: "COMMENTED", Author: struct { + Login string `json:"login"` + }{Login: "bob"}}, + {State: "CHANGES_REQUESTED", Author: struct { + Login string `json:"login"` + }{Login: "bob"}}, + } + got := mergeRequiredReviews(nil, reviews) + if len(got) != 1 { + t.Fatalf("want 1 entry, got %d", len(got)) + } + if got[0].State != ReviewStateChangesRequested { + t.Fatalf("state = %s, want CHANGES_REQUESTED", got[0].State) + } +} + +func TestParseRepoRef(t *testing.T) { + cases := map[string]string{ + "acme/web": "acme/web", + " acme/web ": "acme/web", + "https://github.com/acme/web": "acme/web", + "https://github.com/acme/web.git": "acme/web", + "https://github.com/acme/web/": "acme/web", + "http://github.com/acme/web": "acme/web", + "git@github.com:acme/web.git": "acme/web", + "git@github.com:acme/web": "acme/web", + "https://github.com/a.c/web-thing": "a.c/web-thing", + } + for in, want := range cases { + got, err := ParseRepoRef(in) + if err != nil { + t.Errorf("ParseRepoRef(%q) error: %v", in, err) + continue + } + if got != want { + t.Errorf("ParseRepoRef(%q) = %q, want %q", in, got, want) + } + } + + bad := []string{"", "not-a-repo", "https://gitlab.com/a/b", "owner/", "/repo"} + for _, in := range bad { + if _, err := ParseRepoRef(in); err == nil { + t.Errorf("ParseRepoRef(%q) want error, got nil", in) + } + } +} + +func TestMapCheckRunState(t *testing.T) { + cases := []struct { + status, conclusion string + want CheckState + }{ + {"COMPLETED", "SUCCESS", CheckStateSuccess}, + {"COMPLETED", "FAILURE", CheckStateFailure}, + {"COMPLETED", "TIMED_OUT", CheckStateFailure}, + {"IN_PROGRESS", "", CheckStatePending}, + {"QUEUED", "", CheckStatePending}, + {"COMPLETED", "NEUTRAL", CheckStateNeutral}, + {"COMPLETED", "SKIPPED", CheckStateSkipped}, + } + for _, tc := range cases { + if got := mapCheckRunState(tc.status, tc.conclusion); got != tc.want { + t.Errorf("mapCheckRunState(%q,%q) = %s, want %s", tc.status, tc.conclusion, got, tc.want) + } + } +} diff --git a/internal/github/fetch.go b/internal/github/fetch.go new file mode 100644 index 0000000..ba1befa --- /dev/null +++ b/internal/github/fetch.go @@ -0,0 +1,146 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "time" +) + +// Client is the high-level entry point for fetching PR data. It depends only +// on a GhRunner, which keeps it testable. +type Client struct { + Runner GhRunner +} + +// NewClient returns a Client backed by an ExecRunner using the `gh` binary +// on $PATH. +func NewClient() *Client { return &Client{Runner: NewExecRunner()} } + +// rawResponse mirrors the shape returned by `gh api graphql`. +type rawResponse struct { + Data struct { + Search struct { + Nodes []rawNode `json:"nodes"` + } `json:"search"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` +} + +type rawNode struct { + Typename string `json:"__typename"` + Number int `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + HeadRefName string `json:"headRefName"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + IsDraft bool `json:"isDraft"` + ReviewDecision string `json:"reviewDecision"` + Mergeable string `json:"mergeable"` + MergeStateStatus string `json:"mergeStateStatus"` + Repository struct { + NameWithOwner string `json:"nameWithOwner"` + } `json:"repository"` + Commits struct { + Nodes []struct { + Commit struct { + StatusCheckRollup *struct { + State string `json:"state"` + Contexts struct { + Nodes []rawContext `json:"nodes"` + } `json:"contexts"` + } `json:"statusCheckRollup"` + } `json:"commit"` + } `json:"nodes"` + } `json:"commits"` + ReviewThreads struct { + Nodes []struct { + IsResolved bool `json:"isResolved"` + } `json:"nodes"` + } `json:"reviewThreads"` + ReviewRequests struct { + Nodes []rawReviewRequest `json:"nodes"` + } `json:"reviewRequests"` + LatestReviews struct { + Nodes []rawLatestReview `json:"nodes"` + } `json:"latestReviews"` +} + +type rawContext struct { + Typename string `json:"__typename"` + // CheckRun fields + Name string `json:"name"` + Conclusion string `json:"conclusion"` + Status string `json:"status"` + DetailsURL string `json:"detailsUrl"` + // StatusContext fields + Context string `json:"context"` + State string `json:"state"` + TargetURL string `json:"targetUrl"` +} + +type rawReviewRequest struct { + AsCodeOwner bool `json:"asCodeOwner"` + RequestedReviewer struct { + Typename string `json:"__typename"` + Login string `json:"login"` + Name string `json:"name"` + CombinedSlug string `json:"combinedSlug"` + } `json:"requestedReviewer"` +} + +type rawLatestReview struct { + State string `json:"state"` + Author struct { + Login string `json:"login"` + } `json:"author"` + OnBehalfOf struct { + Nodes []struct { + Name string `json:"name"` + CombinedSlug string `json:"combinedSlug"` + } `json:"nodes"` + } `json:"onBehalfOf"` +} + +// FetchPRs runs the embedded search query against the given repos and +// returns fully-derived PRs. If repos is empty, returns an empty slice +// without calling the API. +func (c *Client) FetchPRs(ctx context.Context, repos []string) ([]PR, error) { + if len(repos) == 0 { + return []PR{}, nil + } + + q := BuildSearchString(repos) + out, err := c.Runner.Run(ctx, + "api", "graphql", + "-f", "query="+SearchQuery(), + "-F", "q="+q, + ) + if err != nil { + return nil, err + } + + var resp rawResponse + if err := json.Unmarshal(out, &resp); err != nil { + return nil, fmt.Errorf("parsing gh response: %w", err) + } + if len(resp.Errors) > 0 { + return nil, fmt.Errorf("graphql error: %s", resp.Errors[0].Message) + } + + prs := make([]PR, 0, len(resp.Data.Search.Nodes)) + for _, n := range resp.Data.Search.Nodes { + if n.Typename != "PullRequest" { + continue + } + prs = append(prs, toPR(n)) + } + sort.SliceStable(prs, func(i, j int) bool { + return prs[i].UpdatedAt.After(prs[j].UpdatedAt) + }) + return prs, nil +} diff --git a/internal/github/graphql.go b/internal/github/graphql.go new file mode 100644 index 0000000..dd7f472 --- /dev/null +++ b/internal/github/graphql.go @@ -0,0 +1,35 @@ +package github + +import ( + _ "embed" + "strings" +) + +//go:embed query.graphql +var searchQuery string + +// SearchQuery returns the embedded GraphQL query used by FetchPRs. +func SearchQuery() string { + return searchQuery +} + +// BuildSearchString builds the GitHub search qualifier string used as the +// GraphQL `$q` variable. It always filters to open PRs authored by the +// authenticated user and restricts to the given repos. +// +// Example output: +// +// is:pr is:open author:@me repo:acme/web repo:acme/api +func BuildSearchString(repos []string) string { + var b strings.Builder + b.WriteString("is:pr is:open author:@me") + for _, r := range repos { + r = strings.TrimSpace(r) + if r == "" { + continue + } + b.WriteString(" repo:") + b.WriteString(r) + } + return b.String() +} diff --git a/internal/github/query.graphql b/internal/github/query.graphql new file mode 100644 index 0000000..8e8e5f4 --- /dev/null +++ b/internal/github/query.graphql @@ -0,0 +1,81 @@ +query ($q: String!) { + search(query: $q, type: ISSUE, first: 100) { + nodes { + __typename + ... on PullRequest { + number + title + url + headRefName + createdAt + updatedAt + isDraft + reviewDecision + mergeable + mergeStateStatus + repository { + nameWithOwner + } + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + state + contexts(first: 100) { + nodes { + __typename + ... on CheckRun { + name + conclusion + status + detailsUrl + } + ... on StatusContext { + context + state + targetUrl + } + } + } + } + } + } + } + reviewThreads(first: 100) { + nodes { + isResolved + } + } + reviewRequests(first: 20) { + nodes { + asCodeOwner + requestedReviewer { + __typename + ... on User { + login + } + ... on Team { + name + combinedSlug + } + } + } + } + latestReviews(first: 50) { + nodes { + state + author { + login + } + onBehalfOf(first: 3) { + nodes { + name + combinedSlug + } + } + } + } + } + } + } +} diff --git a/internal/github/repo.go b/internal/github/repo.go new file mode 100644 index 0000000..dbeebbf --- /dev/null +++ b/internal/github/repo.go @@ -0,0 +1,53 @@ +package github + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" +) + +// ErrRepoNotFound is returned when Validate fails because the repository +// doesn't exist or the authenticated user can't access it. +var ErrRepoNotFound = errors.New("repository not found or inaccessible") + +var ( + httpsRepoRe = regexp.MustCompile(`^https?://github\.com/([^/\s]+)/([^/\s]+?)(?:\.git)?/?$`) + sshRepoRe = regexp.MustCompile(`^git@github\.com:([^/\s]+)/([^/\s]+?)(?:\.git)?$`) + slugRepoRe = regexp.MustCompile(`^([A-Za-z0-9][A-Za-z0-9._-]{0,99})/([A-Za-z0-9._-]{1,100})$`) +) + +// ParseRepoRef normalizes any of the supported input formats (HTTPS URL, +// SSH URL, owner/name slug) to a canonical "owner/name" string. +func ParseRepoRef(input string) (string, error) { + s := strings.TrimSpace(input) + if s == "" { + return "", fmt.Errorf("empty repo reference") + } + for _, re := range []*regexp.Regexp{httpsRepoRe, sshRepoRe, slugRepoRe} { + if m := re.FindStringSubmatch(s); m != nil { + return m[1] + "/" + m[2], nil + } + } + return "", fmt.Errorf("not a recognised GitHub repo reference: %q", input) +} + +// Validate confirms the repo exists and the gh-authenticated user can +// access it by calling `gh api repos/:owner/:name`. Returns ErrRepoNotFound +// on 404/403. +func (c *Client) Validate(ctx context.Context, ownerRepo string) error { + if _, err := ParseRepoRef(ownerRepo); err != nil { + return err + } + _, err := c.Runner.Run(ctx, "api", "repos/"+ownerRepo, "--silent") + if err == nil { + return nil + } + msg := err.Error() + if strings.Contains(msg, "404") || strings.Contains(msg, "Not Found") || + strings.Contains(msg, "403") || strings.Contains(msg, "Forbidden") { + return ErrRepoNotFound + } + return err +} diff --git a/internal/github/runner.go b/internal/github/runner.go new file mode 100644 index 0000000..bbdf822 --- /dev/null +++ b/internal/github/runner.go @@ -0,0 +1,52 @@ +package github + +import ( + "bytes" + "context" + "fmt" + "os/exec" +) + +// GhRunner executes `gh` subcommands. The production implementation shells +// out; tests inject fixtures by implementing this interface. +type GhRunner interface { + Run(ctx context.Context, args ...string) ([]byte, error) +} + +// ExecRunner runs `gh` via os/exec. +type ExecRunner struct { + Binary string // defaults to "gh" +} + +// NewExecRunner returns an ExecRunner using the gh binary on $PATH. +func NewExecRunner() *ExecRunner { return &ExecRunner{Binary: "gh"} } + +// Run invokes `gh args...` and returns stdout. On failure the error message +// includes stderr from gh so the user can see authentication/permission issues. +func (r *ExecRunner) Run(ctx context.Context, args ...string) ([]byte, error) { + bin := r.Binary + if bin == "" { + bin = "gh" + } + cmd := exec.CommandContext(ctx, bin, args...) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + msg := stderr.String() + if msg == "" { + msg = err.Error() + } + return nil, fmt.Errorf("gh %s: %s", argsPreview(args), msg) + } + return stdout.Bytes(), nil +} + +func argsPreview(args []string) string { + if len(args) == 0 { + return "" + } + return args[0] +} diff --git a/internal/github/types.go b/internal/github/types.go new file mode 100644 index 0000000..edcd8a2 --- /dev/null +++ b/internal/github/types.go @@ -0,0 +1,252 @@ +// Package github is a thin wrapper around the local `gh` CLI. It speaks +// GraphQL via `gh api graphql`, exposes typed PR data to the rest of the +// app, and knows nothing about the UI. +package github + +import ( + "fmt" + "time" +) + +// StaleAfter is how long without activity (no pushes, comments, reviews, +// etc.) a PR is considered stale. +const StaleAfter = 28 * 24 * time.Hour + +// ReviewState mirrors GitHub's PullRequestReviewState enum but is kept +// as a string so we can attach "PENDING" (not a real review state) for +// reviewers that were requested but haven't reviewed yet. +type ReviewState string + +const ( + ReviewStatePending ReviewState = "PENDING" + ReviewStateApproved ReviewState = "APPROVED" + ReviewStateChangesRequested ReviewState = "CHANGES_REQUESTED" + ReviewStateCommented ReviewState = "COMMENTED" + ReviewStateDismissed ReviewState = "DISMISSED" +) + +// ReviewerKind distinguishes user reviewers from team reviewers. +type ReviewerKind string + +const ( + ReviewerKindUser ReviewerKind = "user" + ReviewerKindTeam ReviewerKind = "team" +) + +// CheckState is our simplified view of a CI check's outcome. +type CheckState string + +const ( + CheckStateSuccess CheckState = "SUCCESS" + CheckStateFailure CheckState = "FAILURE" + CheckStatePending CheckState = "PENDING" + CheckStateNeutral CheckState = "NEUTRAL" + CheckStateSkipped CheckState = "SKIPPED" + CheckStateUnknown CheckState = "UNKNOWN" +) + +// Check is a single CI check attached to a PR. +type Check struct { + Name string + State CheckState + DetailsURL string +} + +// RequiredReview describes a reviewer whose review is (or was) expected, +// along with their current state. +type RequiredReview struct { + // Name is the display handle: "@login" for users, "@org/team" for teams. + Name string + Kind ReviewerKind + RequiredByCodeOwner bool + State ReviewState + // ApprovedByLogin is set when State=APPROVED and the review was made + // by a user on behalf of a team (gives us "approved by @alice" context). + ApprovedByLogin string +} + +// PR is a pull request with all the data the dashboard needs. It is the +// stable boundary between the github package and the UI. +type PR struct { + Repo string + Number int + Title string + URL string + HeadRef string + CreatedAt time.Time + UpdatedAt time.Time + IsDraft bool + ReviewDecision string // APPROVED / CHANGES_REQUESTED / REVIEW_REQUIRED / "" + + // Mergeable is GitHub's MergeableState enum: MERGEABLE, + // CONFLICTING, or UNKNOWN. Tells us whether the HEAD would merge + // cleanly into the base if we tried. + Mergeable string + // MergeStateStatus is GitHub's MergeStateStatus enum with more + // context than Mergeable alone: CLEAN, DIRTY, BLOCKED, BEHIND, + // DRAFT, HAS_HOOKS, UNSTABLE, UNKNOWN. See MergeBlockReasons for + // how we translate this into human-readable blockers. + MergeStateStatus string + + Checks []Check + FailingChecks []Check + PendingChecks int + TotalChecks int + UnresolvedCount int + RequiredReviews []RequiredReview +} + +// IsStale reports whether the PR has had no activity for more than +// StaleAfter. "Activity" here is whatever GitHub bumps updatedAt for — +// commits, comments, reviews, label changes, etc. +func (p PR) IsStale() bool { + return !p.UpdatedAt.IsZero() && time.Since(p.UpdatedAt) > StaleAfter +} + +// ApprovalCount returns how many requested reviewers have approved. +// This counts across every entry in RequiredReviews (both codeowner- +// required and ordinarily-requested reviewers). +func (p PR) ApprovalCount() int { + n := 0 + for _, r := range p.RequiredReviews { + if r.State == ReviewStateApproved { + n++ + } + } + return n +} + +// HasChangesRequested reports whether any requested reviewer requested +// changes. Like ApprovalCount, this spans every entry. +func (p PR) HasChangesRequested() bool { + for _, r := range p.RequiredReviews { + if r.State == ReviewStateChangesRequested { + return true + } + } + return false +} + +// CodeOwnerReviews returns only the reviewers that GitHub considers +// "required" — i.e. those pulled in via CODEOWNERS (asCodeOwner=true on +// the underlying review request). GitHub doesn't expose branch- +// protection minimum-approvals count without admin scope, so CODEOWNERS +// is the strongest permission-free signal we have for "required". +func (p PR) CodeOwnerReviews() []RequiredReview { + out := make([]RequiredReview, 0, len(p.RequiredReviews)) + for _, r := range p.RequiredReviews { + if r.RequiredByCodeOwner { + out = append(out, r) + } + } + return out +} + +// OptionalReviews returns the reviewers that were requested on the PR +// but are not CODEOWNERS-required (e.g. manually added reviewers or +// team requests that don't gate the merge). +func (p PR) OptionalReviews() []RequiredReview { + out := make([]RequiredReview, 0, len(p.RequiredReviews)) + for _, r := range p.RequiredReviews { + if !r.RequiredByCodeOwner { + out = append(out, r) + } + } + return out +} + +// CodeOwnerApprovalCount reports how many CODEOWNERS-required reviewers +// have approved. +func (p PR) CodeOwnerApprovalCount() int { + n := 0 + for _, r := range p.RequiredReviews { + if r.RequiredByCodeOwner && r.State == ReviewStateApproved { + n++ + } + } + return n +} + +// CodeOwnerHasChangesRequested reports whether any CODEOWNERS-required +// reviewer has requested changes. +func (p PR) CodeOwnerHasChangesRequested() bool { + for _, r := range p.RequiredReviews { + if r.RequiredByCodeOwner && r.State == ReviewStateChangesRequested { + return true + } + } + return false +} + +// MergeBlockReasons returns a short list of human-readable reasons the +// PR can't be merged right now, in rough order of severity. An empty +// slice means the PR is mergeable (or GitHub hasn't reported any +// blockers). +// +// Sources, in priority order: +// 1. mergeStateStatus — the most specific signal GitHub gives us +// (DIRTY/BEHIND/DRAFT/BLOCKED/UNSTABLE/UNKNOWN/CLEAN/HAS_HOOKS). +// 2. For BLOCKED — which just means "branch protection is blocking +// the merge" without saying why — we synthesise likely reasons +// from observable signals: the PR's reviewDecision, CODEOWNERS +// approvals, failing status checks, and pending checks. +// 3. As a fallback, a CONFLICTING value on mergeable is surfaced as +// a conflict. Older GitHub instances sometimes populate mergeable +// before mergeStateStatus. +func (p PR) MergeBlockReasons() []string { + switch p.MergeStateStatus { + case "CLEAN", "HAS_HOOKS": + return nil + case "DIRTY": + return []string{"merge conflicts with base branch"} + case "BEHIND": + return []string{"branch is behind base"} + case "DRAFT": + return []string{"PR is a draft"} + case "UNSTABLE": + if len(p.FailingChecks) > 0 { + return []string{fmt.Sprintf("%d failing check(s) (non-blocking)", len(p.FailingChecks))} + } + return []string{"non-required checks failing"} + case "BLOCKED": + return p.blockedReasons() + case "UNKNOWN": + return []string{"mergeability still being computed by GitHub"} + } + + // mergeStateStatus was empty (older API, preview permissions, or + // an unknown value we haven't enumerated). Fall back to whatever + // the legacy mergeable field tells us. + if p.Mergeable == "CONFLICTING" { + return []string{"merge conflicts with base branch"} + } + return nil +} + +// blockedReasons synthesises likely reasons the PR is in MergeState +// BLOCKED. GitHub's API doesn't expose which branch-protection rule +// is failing, so we list every observable condition that typically +// blocks a merge: changes requested, missing required approvals, +// failing checks, and pending checks. +func (p PR) blockedReasons() []string { + var r []string + switch p.ReviewDecision { + case "CHANGES_REQUESTED": + r = append(r, "changes requested by reviewer") + case "REVIEW_REQUIRED": + r = append(r, "missing required approvals") + } + if len(p.FailingChecks) > 0 { + r = append(r, fmt.Sprintf("%d failing status check(s)", len(p.FailingChecks))) + } + if p.PendingChecks > 0 { + r = append(r, fmt.Sprintf("%d check(s) still running", p.PendingChecks)) + } + if len(r) == 0 { + // GitHub marked this as blocked but none of our heuristics + // hit — most likely a branch-protection rule we can't see + // (e.g. required-status-checks list, signed commits). + r = append(r, "blocked by branch protection") + } + return r +} diff --git a/internal/ui/app.go b/internal/ui/app.go new file mode 100644 index 0000000..682cfa5 --- /dev/null +++ b/internal/ui/app.go @@ -0,0 +1,213 @@ +package ui + +import ( + "time" + + "github.com/bluegardenproject/github-butler/internal/config" + "github.com/bluegardenproject/github-butler/internal/github" + "github.com/bluegardenproject/github-butler/internal/ui/components" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// screen identifies the currently active view. +type screen int + +const ( + screenDashboard screen = iota + screenMenu + screenRepos + screenAddRepo + screenConfirmRemove + screenSettings + screenEditInterval +) + +// Model is the single Bubble Tea model backing every screen. Per-screen +// transient state (list cursors, text inputs) lives here too so switching +// screens is a simple enum change. +type Model struct { + cfg config.Config + client *github.Client + + // data + prs []github.PR + lastFetched time.Time + nextTick time.Time + loading bool + err error + + // dashboard state + selected int + + // screen stack (only one level deep is needed) + screen screen + + // menu / repos / settings cursors + menuCursor int + reposCursor int + settingsCursor int + + // inputs + addInput textinput.Model + intervalInput textinput.Model + + // validation + validating bool + validationErr string + + // toast + toast components.Toast + toastID int64 + + // layout + width, height int +} + +// NewModel constructs the root model. +func NewModel(cfg config.Config, client *github.Client) Model { + addIn := textinput.New() + addIn.Placeholder = "owner/repo or https://github.com/owner/repo" + addIn.CharLimit = 200 + addIn.Width = 60 + + intervalIn := textinput.New() + intervalIn.CharLimit = 5 + intervalIn.Width = 10 + intervalIn.Placeholder = "seconds" + + return Model{ + cfg: cfg, + client: client, + screen: screenDashboard, + addInput: addIn, + intervalInput: intervalIn, + // Seed the countdown so the bar/"next: Ns" hint render correctly + // on the very first frame, before the first tickMsg arrives. + // Init() can't do this because it has a value receiver. + nextTick: time.Now().Add(cfg.PollInterval()), + } +} + +// Init kicks off the first fetch and the recurring ticks: a slow poll +// tick that drives data refresh, and a fast UI tick that just repaints +// the countdown bar and relative timestamps. +func (m Model) Init() tea.Cmd { + return tea.Batch( + fetchPRsCmd(m.client, m.cfg.Repos), + tickCmd(m.cfg.PollInterval()), + uiTickCmd(), + ) +} + +// Update dispatches messages to per-screen handlers, with a small set of +// always-on globals (quit, size, tick, fetched, errors, toast expiry). +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + return m, nil + + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + + case tickMsg: + if m.loading { + return m, tickCmd(m.cfg.PollInterval()) + } + m.loading = true + m.nextTick = time.Now().Add(m.cfg.PollInterval()) + return m, tea.Batch( + fetchPRsCmd(m.client, m.cfg.Repos), + tickCmd(m.cfg.PollInterval()), + ) + + case uiTickMsg: + return m, uiTickCmd() + + case prsFetchedMsg: + m.loading = false + m.prs = OrderPRs(msg.PRs, m.cfg.GroupByRepo, m.cfg.Repos) + m.lastFetched = msg.Fetched + m.err = nil + if m.selected >= len(m.prs) { + m.selected = len(m.prs) - 1 + } + if m.selected < 0 { + m.selected = 0 + } + return m, nil + + case errMsg: + m.loading = false + m.err = msg.Err + return m.showToast("fetch failed: "+msg.Err.Error(), components.ToastError) + + case configSavedMsg: + m2, cmd := m.showToast("saved", components.ToastSuccess) + return m2, tea.Batch(cmd, fetchPRsCmd(m.client, m.cfg.Repos)) + + case repoValidatedMsg: + return m.handleRepoValidated(msg) + } + + // toast expiry + if id, ok := components.ExpiryID(msg); ok { + if id == m.toastID { + m.toast = components.Toast{} + } + return m, nil + } + + switch m.screen { + case screenDashboard: + return m.updateDashboard(msg) + case screenMenu: + return m.updateMenu(msg) + case screenRepos: + return m.updateRepos(msg) + case screenAddRepo: + return m.updateAddRepo(msg) + case screenConfirmRemove: + return m.updateConfirmRemove(msg) + case screenSettings: + return m.updateSettings(msg) + case screenEditInterval: + return m.updateEditInterval(msg) + } + return m, nil +} + +// View renders the current screen wrapped in the outer border. +func (m Model) View() string { + if m.width == 0 { + return "Loading..." + } + + var body string + switch m.screen { + case screenDashboard: + body = m.viewDashboard() + case screenMenu: + body = m.viewMenu() + case screenRepos, screenAddRepo, screenConfirmRemove: + body = m.viewRepos() + case screenSettings, screenEditInterval: + body = m.viewSettings() + } + + if m.toast.Message != "" { + body = lipgloss.JoinVertical(lipgloss.Left, body, m.toast.Render()) + } + return body +} + +func (m Model) showToast(msg string, level components.ToastLevel) (Model, tea.Cmd) { + toast, id, cmd := components.Show(msg, level, 2500*time.Millisecond) + m.toast = toast + m.toastID = id + return m, cmd +} diff --git a/internal/ui/commands.go b/internal/ui/commands.go new file mode 100644 index 0000000..c72a42d --- /dev/null +++ b/internal/ui/commands.go @@ -0,0 +1,77 @@ +package ui + +import ( + "context" + "os/exec" + "runtime" + "time" + + "github.com/bluegardenproject/github-butler/internal/config" + "github.com/bluegardenproject/github-butler/internal/github" + tea "github.com/charmbracelet/bubbletea" +) + +// Factory functions returning tea.Cmd values. Keeping them here separates +// the "how to trigger side effects" from model state transitions. + +func tickCmd(d time.Duration) tea.Cmd { + return tea.Tick(d, func(t time.Time) tea.Msg { return tickMsg(t) }) +} + +// uiTickInterval is how often we repaint the countdown bar and relative +// timestamps. Fast enough to feel live, slow enough to be cheap. +const uiTickInterval = 250 * time.Millisecond + +func uiTickCmd() tea.Cmd { + return tea.Tick(uiTickInterval, func(t time.Time) tea.Msg { return uiTickMsg(t) }) +} + +func fetchPRsCmd(client *github.Client, repos []string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + prs, err := client.FetchPRs(ctx, repos) + if err != nil { + return errMsg{Err: err} + } + return prsFetchedMsg{PRs: prs, Fetched: time.Now()} + } +} + +func validateRepoCmd(client *github.Client, slug string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + err := client.Validate(ctx, slug) + return repoValidatedMsg{Slug: slug, Err: err} + } +} + +func saveConfigCmd(cfg config.Config) tea.Cmd { + return func() tea.Msg { + if err := config.Save(cfg); err != nil { + return errMsg{Err: err} + } + return configSavedMsg{} + } +} + +// openURLCmd opens a URL in the user's default browser using the OS-native +// open tool. Errors are surfaced as errMsg but don't disrupt navigation. +func openURLCmd(url string) tea.Cmd { + return func() tea.Msg { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + cmd = exec.Command("xdg-open", url) + } + if err := cmd.Start(); err != nil { + return errMsg{Err: err} + } + return nil + } +} diff --git a/internal/ui/components/banner.go b/internal/ui/components/banner.go new file mode 100644 index 0000000..ff52e16 --- /dev/null +++ b/internal/ui/components/banner.go @@ -0,0 +1,71 @@ +// Package components holds small reusable widgets shared by views. +package components + +import ( + "strings" + + "github.com/bluegardenproject/github-butler/internal/ui/theme" +) + +// Banner renders the app title with a neon gradient. +func Banner(text string) string { + return theme.Gradient(text, theme.TitleStops...) +} + +// bigLetters is a 3-row block font covering just the characters we need +// for "GITHUB-BUTLER". Every glyph is 4 columns wide so letters line up +// cleanly when BigBanner joins them with a single-column separator. +// +// Each glyph uses Unicode quadrant and half blocks (U+2580…U+259F) so +// that within a 3-text-row height we can effectively draw at 6-pixel +// vertical resolution (two half-rows per text row) and 8-pixel +// horizontal resolution (two half-cols per text column). The corner +// quadrants (▟ ▙ ▜ ▛) give rounded outer corners; half-blocks (▀ ▄) +// let us draw single-pixel-tall horizontal strokes. +// +// If BigBanner is ever asked to render a rune not in this map it falls +// back to a solid block, so the banner still renders rather than +// panicking on unexpected input. +var bigLetters = map[rune][3]string{ + 'G': {"▟██▙", "█ ▄▄", "▜▄▄▛"}, + 'I': {"████", " ██ ", "████"}, + 'T': {"████", " ██ ", " ██ "}, + 'H': {"██ █", "████", "██ █"}, + 'U': {"██ █", "██ █", "████"}, + 'B': {"██▀▙", "██▀▙", "██▄▛"}, + 'E': {"█▀▀▀", "█▀▀ ", "████"}, + 'L': {"██ ", "██ ", "████"}, + 'R': {"██▀▙", "██▄▛", "█ ▜▄"}, + '-': {" ", "▄▄▄▄", " "}, + ' ': {" ", " ", " "}, +} + +// BigBanner renders text as a 3-line block-letter banner, gradient- +// coloured with the shared title palette. Input is upper-cased before +// lookup so callers don't have to care about case. +// +// The banner is meant for the top of a view (currently only the +// dashboard uses it). Returned string already contains its own +// newlines; the caller just needs to place it with JoinVertical. +func BigBanner(text string) string { + runes := []rune(strings.ToUpper(text)) + + var rows [3]strings.Builder + for i, r := range runes { + glyph, ok := bigLetters[r] + if !ok { + glyph = [3]string{"████", "████", "████"} + } + for row := 0; row < 3; row++ { + rows[row].WriteString(glyph[row]) + if i < len(runes)-1 { + rows[row].WriteString(" ") + } + } + } + var lines [3]string + for i := 0; i < 3; i++ { + lines[i] = theme.Gradient(rows[i].String(), theme.TitleStops...) + } + return strings.Join(lines[:], "\n") +} diff --git a/internal/ui/components/confirm.go b/internal/ui/components/confirm.go new file mode 100644 index 0000000..607bff8 --- /dev/null +++ b/internal/ui/components/confirm.go @@ -0,0 +1,18 @@ +package components + +import ( + "github.com/bluegardenproject/github-butler/internal/ui/theme" + "github.com/charmbracelet/lipgloss" +) + +// Confirm renders a small y/n prompt line. Views handle the keypresses; +// this helper just renders the prompt. +func Confirm(prompt string) string { + yes := theme.KeyHint.Render("y") + no := theme.KeyHint.Render("n") + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(theme.NeonYellow). + Padding(0, 1). + Render(prompt + " " + yes + theme.KeyLabel.Render(" yes ") + no + theme.KeyLabel.Render(" no")) +} diff --git a/internal/ui/components/countdown.go b/internal/ui/components/countdown.go new file mode 100644 index 0000000..c28d351 --- /dev/null +++ b/internal/ui/components/countdown.go @@ -0,0 +1,36 @@ +package components + +import ( + "strings" + + "github.com/bluegardenproject/github-butler/internal/ui/theme" +) + +// Countdown renders a width-wide horizontal gradient progress bar showing +// how much time remains until the next refresh. remaining/total is clamped +// to [0,1]. Filled runes fade through CountdownStops; empty runes are dim. +func Countdown(remaining, total float64, width int) string { + if width <= 0 { + return "" + } + if total <= 0 { + total = 1 + } + ratio := remaining / total + if ratio < 0 { + ratio = 0 + } + if ratio > 1 { + ratio = 1 + } + filled := int(ratio * float64(width)) + if filled > width { + filled = width + } + + fillText := strings.Repeat("█", filled) + emptyText := strings.Repeat("░", width-filled) + + return theme.Gradient(fillText, theme.CountdownStops...) + + theme.Dimmed.Render(emptyText) +} diff --git a/internal/ui/components/spinner.go b/internal/ui/components/spinner.go new file mode 100644 index 0000000..012e20d --- /dev/null +++ b/internal/ui/components/spinner.go @@ -0,0 +1,41 @@ +package components + +import ( + "time" + + "github.com/bluegardenproject/github-butler/internal/ui/theme" + "github.com/charmbracelet/lipgloss" +) + +// spinnerFrames is a classic braille spinner — small, monospace-friendly, +// and renders well in every terminal we care about. +var spinnerFrames = []string{ + "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", +} + +// spinnerFrameInterval is how long each frame is shown. Matches the UI +// repaint cadence (uiTickInterval) so we advance roughly one frame per +// repaint and the animation looks smooth without extra timers. +const spinnerFrameInterval = 250 * time.Millisecond + +// Spinner returns the current spinner glyph styled in NeonCyan. It is +// stateless: the frame is derived from the wall clock, so callers don't +// need to track an index — they just call Spinner() on every render and +// the existing UI tick will naturally advance the animation. +func Spinner() string { + idx := int(time.Now().UnixNano()/int64(spinnerFrameInterval)) % len(spinnerFrames) + if idx < 0 { + idx += len(spinnerFrames) + } + return lipgloss.NewStyle(). + Foreground(theme.NeonCyan). + Bold(true). + Render(spinnerFrames[idx]) +} + +// Loading returns a spinner glyph followed by a styled label, e.g. +// "⠋ refreshing". Use this for inline "something is happening" hints +// next to titles or in the footer. +func Loading(label string) string { + return Spinner() + " " + theme.Info.Render(label) +} diff --git a/internal/ui/components/toast.go b/internal/ui/components/toast.go new file mode 100644 index 0000000..8575c70 --- /dev/null +++ b/internal/ui/components/toast.go @@ -0,0 +1,67 @@ +package components + +import ( + "time" + + "github.com/bluegardenproject/github-butler/internal/ui/theme" + tea "github.com/charmbracelet/bubbletea" +) + +// ToastLevel controls toast styling. +type ToastLevel int + +const ( + ToastInfo ToastLevel = iota + ToastSuccess + ToastError +) + +// Toast is an ephemeral message pinned to a corner of the screen. +type Toast struct { + Message string + Level ToastLevel + Expires time.Time +} + +// toastExpiredMsg is emitted when a toast should disappear. +type toastExpiredMsg struct{ id int64 } + +// Show returns a new Toast plus a tea.Cmd that will emit an expiry message +// after the given duration. The returned int64 id lets callers correlate +// the expiry message with this specific toast (so a later toast doesn't +// get dismissed by an older timer). +func Show(message string, level ToastLevel, ttl time.Duration) (Toast, int64, tea.Cmd) { + id := time.Now().UnixNano() + t := Toast{ + Message: message, + Level: level, + Expires: time.Now().Add(ttl), + } + return t, id, tea.Tick(ttl, func(time.Time) tea.Msg { + return toastExpiredMsg{id: id} + }) +} + +// ExpiryID returns the id from a toastExpiredMsg if the message matches. +// Views call this from their Update to know whether to dismiss the toast. +func ExpiryID(msg tea.Msg) (int64, bool) { + if m, ok := msg.(toastExpiredMsg); ok { + return m.id, true + } + return 0, false +} + +// Render returns the styled toast string. +func (t Toast) Render() string { + if t.Message == "" { + return "" + } + switch t.Level { + case ToastSuccess: + return theme.SuccessToast.Render("✓ " + t.Message) + case ToastError: + return theme.ErrorToast.Render("✗ " + t.Message) + default: + return theme.Info.Render(t.Message) + } +} diff --git a/internal/ui/dashboard.go b/internal/ui/dashboard.go new file mode 100644 index 0000000..f200129 --- /dev/null +++ b/internal/ui/dashboard.go @@ -0,0 +1,494 @@ +package ui + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/bluegardenproject/github-butler/internal/github" + "github.com/bluegardenproject/github-butler/internal/ui/components" + "github.com/bluegardenproject/github-butler/internal/ui/theme" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Column widths for the dashboard table. Kept here so header + rows use the +// same values. +const ( + colRepoW = 22 + colNumW = 6 + colTitleW = 52 + colTagsW = 15 + colBranchW = 22 + colCIW = 12 + colRevW = 14 + colReqW = 7 + colUnresW = 6 + colAgeW = 8 + colActW = 8 +) + +func (m Model) updateDashboard(msg tea.Msg) (tea.Model, tea.Cmd) { + km, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch { + case key.Matches(km, keys.Quit): + return m, tea.Quit + case key.Matches(km, keys.Menu): + m.screen = screenMenu + m.menuCursor = 0 + return m, nil + case key.Matches(km, keys.Refresh): + if !m.loading { + m.loading = true + return m, fetchPRsCmd(m.client, m.cfg.Repos) + } + case key.Matches(km, keys.Up): + if m.selected > 0 { + m.selected-- + } + case key.Matches(km, keys.Down): + if m.selected < len(m.prs)-1 { + m.selected++ + } + case key.Matches(km, keys.OpenPR), key.Matches(km, keys.Select): + if len(m.prs) > 0 && m.selected >= 0 && m.selected < len(m.prs) { + return m, openURLCmd(m.prs[m.selected].URL) + } + } + return m, nil +} + +func (m Model) viewDashboard() string { + bigBanner := components.BigBanner("GITHUB-BUTLER") + smallBanner := components.Banner(" PR DASHBOARD ") + subtitle := theme.Dimmed.Render(fmt.Sprintf("%d PR(s) across %d repo(s)", len(m.prs), len(m.cfg.Repos))) + + var body string + switch { + case len(m.cfg.Repos) == 0: + body = theme.Panel.Render(theme.Pending.Render("No repositories configured.") + + "\n\n" + + theme.KeyLabel.Render("Press ") + + theme.KeyHint.Render("m") + + theme.KeyLabel.Render(" to open the menu and add one.")) + case len(m.prs) == 0 && m.loading: + body = theme.Panel.Render(components.Loading("Fetching PRs...")) + case len(m.prs) == 0: + body = theme.Panel.Render(theme.Dimmed.Render("No open PRs authored by you in the configured repos.")) + default: + body = m.renderTable() + } + + detail := m.renderDetail() + footer := m.renderFooter() + + // Once we have data, subsequent fetches don't replace the table — + // they happen in the background. Surface them with a small inline + // spinner next to the subtitle so the user sees something is + // happening even though the existing rows stay visible. + subHeaderParts := []string{smallBanner, " ", subtitle} + if m.loading && len(m.prs) > 0 { + subHeaderParts = append(subHeaderParts, " ", components.Loading("refreshing")) + } + subHeader := lipgloss.JoinHorizontal(lipgloss.Bottom, subHeaderParts...) + return lipgloss.JoinVertical(lipgloss.Left, bigBanner, "", subHeader, body, detail, footer) +} + +func (m Model) renderTable() string { + header := renderHeaderRow() + rows := make([]string, 0, len(m.prs)+1) + rows = append(rows, header) + + var prevRepo string + for i, pr := range m.prs { + if m.cfg.GroupByRepo && pr.Repo != prevRepo { + rows = append(rows, renderGroupHeader(pr.Repo)) + prevRepo = pr.Repo + } + rows = append(rows, m.renderRow(pr, i == m.selected)) + } + return theme.Panel.Render(strings.Join(rows, "\n")) +} + +// renderGroupHeader renders a single separator row above each repo group +// when group-by-repo is enabled. Kept visually light so it doesn't +// compete with the gradient table header. +func renderGroupHeader(repo string) string { + label := theme.Gradient("▸ "+repo, theme.NeonPink, theme.NeonCyan) + return label +} + +// OrderPRs returns a copy of prs ordered for display on the dashboard. +// +// When groupByRepo is false the slice is sorted "most recently updated +// first" — the canonical list ordering used everywhere else. +// +// When groupByRepo is true PRs belonging to the same repo sit next to +// each other; the relative order within a group remains "most recently +// updated first". Repo groups themselves are ordered to match the +// user's configured repo list, with any repos not in that list +// appended alphabetically at the end. +// +// Toggling the flag re-applies the correct order to the existing slice +// so the UI updates immediately, without waiting for the next fetch. +func OrderPRs(prs []github.PR, groupByRepo bool, repoOrder []string) []github.PR { + out := make([]github.PR, len(prs)) + copy(out, prs) + if !groupByRepo { + sort.SliceStable(out, func(i, j int) bool { + return out[i].UpdatedAt.After(out[j].UpdatedAt) + }) + return out + } + rank := make(map[string]int, len(repoOrder)) + for i, r := range repoOrder { + rank[r] = i + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Repo != out[j].Repo { + ri, oki := rank[out[i].Repo] + rj, okj := rank[out[j].Repo] + switch { + case oki && okj: + return ri < rj + case oki: + return true + case okj: + return false + default: + return out[i].Repo < out[j].Repo + } + } + return out[i].UpdatedAt.After(out[j].UpdatedAt) + }) + return out +} + +func renderHeaderRow() string { + cells := []string{ + pad("REPO", colRepoW), + pad("#", colNumW), + pad("TITLE", colTitleW), + pad("TAGS", colTagsW), + pad("BRANCH", colBranchW), + pad("CI", colCIW), + pad("REVIEW", colRevW), + pad("REQ", colReqW), + pad("UNRES", colUnresW), + pad("AGE", colAgeW), + pad("ACT", colActW), + } + return theme.Gradient(strings.Join(cells, " "), theme.HeaderStops...) +} + +func (m Model) renderRow(pr github.PR, selected bool) string { + repo := pad(shortRepo(pr.Repo), colRepoW) + num := pad(fmt.Sprintf("#%d", pr.Number), colNumW) + title := pad(truncate(pr.Title, colTitleW), colTitleW) + + tags := padVisible(renderTags(pr), colTagsW) + branch := pad(truncate(pr.HeadRef, colBranchW), colBranchW) + ci := padVisible(renderCIStatus(pr), colCIW) + rev := padVisible(renderReviewStatus(pr), colRevW) + req := padVisible(renderRequiredStatus(pr), colReqW) + unres := padVisible(renderUnresolved(pr.UnresolvedCount), colUnresW) + age := pad(relativeTime(pr.CreatedAt), colAgeW) + act := pad(relativeTime(pr.UpdatedAt), colActW) + + // Only the first three columns participate in the selection highlight. + // The columns to the right contain styled spans (chips with their own + // background, colored CI/review states, etc.) whose ANSI resets would + // otherwise truncate the selected-row background inconsistently + // depending on which chips happen to be present. + leading := strings.Join([]string{repo, num, title}, " ") + if selected { + leading = theme.SelectedRow.Render(leading) + } + trailing := strings.Join([]string{tags, branch, ci, rev, req, unres, age, act}, " ") + return leading + " " + trailing +} + +// renderTags joins the DRAFT/STALE chips for a PR into a single styled +// string. Empty when the PR has neither flag; the row renderer pads the +// result out to colTagsW so every column to the right stays aligned. +func renderTags(pr github.PR) string { + var chips []string + if pr.IsDraft { + chips = append(chips, theme.DraftChip.Render("DRAFT")) + } + if pr.IsStale() { + chips = append(chips, theme.StaleChip.Render("STALE")) + } + return strings.Join(chips, " ") +} + +func shortRepo(full string) string { + if i := strings.Index(full, "/"); i >= 0 { + return full[i+1:] + } + return full +} + +func renderCIStatus(pr github.PR) string { + switch { + case pr.TotalChecks == 0: + return theme.Dimmed.Render("—") + case len(pr.FailingChecks) > 0: + return theme.Fail.Render(fmt.Sprintf("FAIL ×%d", len(pr.FailingChecks))) + case pr.PendingChecks > 0: + return theme.Pending.Render(fmt.Sprintf("RUNNING ×%d", pr.PendingChecks)) + default: + return theme.OK.Render("PASS") + } +} + +func renderReviewStatus(pr github.PR) string { + required := len(pr.RequiredReviews) + approved := pr.ApprovalCount() + + var base string + switch pr.ReviewDecision { + case "APPROVED": + base = theme.OK.Render("APPROVED") + case "CHANGES_REQUESTED": + base = theme.Fail.Render("CHANGES") + case "REVIEW_REQUIRED": + base = theme.Info.Render("REQUIRED") + default: + if required > 0 { + base = theme.Info.Render("PENDING") + } else { + base = theme.Dimmed.Render("—") + } + } + if required > 0 { + ratio := lipgloss.NewStyle().Foreground(theme.NeonYellow).Render(fmt.Sprintf(" %d/%d", approved, required)) + base += ratio + } + if pr.HasChangesRequested() && pr.ReviewDecision != "CHANGES_REQUESTED" { + base += theme.Fail.Render("!") + } + return base +} + +// renderRequiredStatus renders the REQ column: approved / total among +// CODEOWNERS-required reviewers. GitHub doesn't expose branch-protection +// rules without admin scope, so CODEOWNERS is the best permission-free +// stand-in for "required to merge". +// +// Color rules mirror the existing CI/Review conventions: +// - no required reviewers → dim "—" +// - any changes requested → red "✗ a/t" +// - all approved (a == t) → green "✓ a/t" +// - otherwise (pending) → cyan "a/t" +func renderRequiredStatus(pr github.PR) string { + total := len(pr.CodeOwnerReviews()) + if total == 0 { + return theme.Dimmed.Render("—") + } + approved := pr.CodeOwnerApprovalCount() + ratio := fmt.Sprintf("%d/%d", approved, total) + switch { + case pr.CodeOwnerHasChangesRequested(): + return theme.Fail.Render("✗ " + ratio) + case approved == total: + return theme.OK.Render("✓ " + ratio) + default: + return theme.Info.Render(ratio) + } +} + +func renderUnresolved(n int) string { + if n == 0 { + return theme.Dimmed.Render("0") + } + return lipgloss.NewStyle().Foreground(theme.NeonOrange).Bold(true).Render(fmt.Sprintf("%d", n)) +} + +func relativeTime(t time.Time) string { + if t.IsZero() { + return "—" + } + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + case d < 30*24*time.Hour: + return fmt.Sprintf("%dd", int(d.Hours()/24)) + default: + return fmt.Sprintf("%dw", int(d.Hours()/(24*7))) + } +} + +func (m Model) renderDetail() string { + if len(m.prs) == 0 || m.selected < 0 || m.selected >= len(m.prs) { + return "" + } + pr := m.prs[m.selected] + + title := theme.PanelTitle.Render( + theme.Gradient("■ Details ", theme.NeonPink, theme.NeonCyan) + + theme.Dimmed.Render(pr.URL), + ) + + checks := renderChecksSection(pr) + reviews := renderReviewsSection(pr) + merge := renderMergeSection(pr) + + gap := lipgloss.NewStyle().Width(2).Render(" ") + bottomRow := lipgloss.JoinHorizontal(lipgloss.Top, + reviews, + gap, + merge, + ) + body := lipgloss.JoinVertical(lipgloss.Left, checks, "", bottomRow) + return theme.Panel.Render(lipgloss.JoinVertical(lipgloss.Left, title, body)) +} + +// renderMergeSection renders the rightmost "MERGE" column in the detail +// pane. When the PR is mergeable it shows a green "ready to merge"; +// otherwise it lists every blocker returned by PR.MergeBlockReasons() +// one per line, with a severity-appropriate color. +func renderMergeSection(pr github.PR) string { + header := theme.Gradient("MERGE", theme.NeonOrange, theme.NeonPink) + reasons := pr.MergeBlockReasons() + if len(reasons) == 0 { + state := theme.OK.Render(" ✓ ready to merge") + return lipgloss.JoinVertical(lipgloss.Left, header, state) + } + style := mergeReasonStyle(pr.MergeStateStatus) + lines := []string{header} + for _, r := range reasons { + lines = append(lines, style.Render(" ✗ "+r)) + } + if pr.MergeStateStatus != "" { + lines = append(lines, theme.Dimmed.Render(" ("+strings.ToLower(pr.MergeStateStatus)+")")) + } + return strings.Join(lines, "\n") +} + +// mergeReasonStyle picks the color for blocker lines based on the +// severity of the underlying mergeStateStatus. Hard blockers +// (conflicts / changes requested / behind) render red; soft blockers +// (pending checks, unknown) render cyan so they don't scream. +func mergeReasonStyle(status string) lipgloss.Style { + switch status { + case "DIRTY", "BEHIND", "BLOCKED": + return theme.Fail + case "DRAFT", "UNSTABLE": + return theme.Pending + case "UNKNOWN", "": + return theme.Info + default: + return theme.Fail + } +} + +func renderChecksSection(pr github.PR) string { + header := theme.Gradient("CHECKS", theme.NeonPink, theme.NeonMagenta) + if pr.TotalChecks == 0 { + return lipgloss.JoinVertical(lipgloss.Left, header, theme.Dimmed.Render(" none")) + } + lines := []string{header} + if len(pr.FailingChecks) == 0 { + lines = append(lines, theme.OK.Render(" ✓ all passing")) + } else { + for _, c := range pr.FailingChecks { + line := theme.Fail.Render(" ✗ " + c.Name) + if c.DetailsURL != "" { + line += theme.Dimmed.Render(" " + c.DetailsURL) + } + lines = append(lines, line) + } + } + if pr.PendingChecks > 0 { + lines = append(lines, theme.Pending.Render(fmt.Sprintf(" … %d running", pr.PendingChecks))) + } + return strings.Join(lines, "\n") +} + +func renderReviewsSection(pr github.PR) string { + header := theme.Gradient("REVIEWS", theme.NeonCyan, theme.NeonPurple) + if len(pr.RequiredReviews) == 0 { + return lipgloss.JoinVertical(lipgloss.Left, header, theme.Dimmed.Render(" none")) + } + + required := pr.CodeOwnerReviews() + optional := pr.OptionalReviews() + + lines := []string{header} + lines = append(lines, renderReviewGroup("REQUIRED (CODEOWNERS)", required)...) + if len(optional) > 0 { + lines = append(lines, "") + lines = append(lines, renderReviewGroup("OPTIONAL", optional)...) + } + return strings.Join(lines, "\n") +} + +// renderReviewGroup renders a subsection of the reviews pane with its +// own subtitle and one line per reviewer. Returns "(none)" when the +// group is empty so the subtitle stays meaningful. +func renderReviewGroup(title string, reviews []github.RequiredReview) []string { + sub := theme.Dimmed.Render(" " + title) + if len(reviews) == 0 { + return []string{sub, theme.Dimmed.Render(" (none)")} + } + lines := []string{sub} + for _, r := range reviews { + lines = append(lines, renderReviewLine(r)) + } + return lines +} + +// renderReviewLine formats a single reviewer row: status marker, +// handle, and optional "via @login" attribution for approvals made on +// behalf of a team. +func renderReviewLine(r github.RequiredReview) string { + var marker string + switch r.State { + case github.ReviewStateApproved: + marker = theme.OK.Render(" ✓") + case github.ReviewStateChangesRequested: + marker = theme.Fail.Render(" ✗") + case github.ReviewStateCommented: + marker = theme.Info.Render(" ●") + default: + marker = theme.Pending.Render(" …") + } + name := lipgloss.NewStyle().Bold(true).Render(r.Name) + tail := "" + if r.ApprovedByLogin != "" && r.State == github.ReviewStateApproved { + tail = theme.Dimmed.Render(" via @" + r.ApprovedByLogin) + } + return fmt.Sprintf("%s %s%s", marker, name, tail) +} + +func (m Model) renderFooter() string { + var left string + if m.lastFetched.IsZero() { + left = theme.Dimmed.Render("never refreshed") + } else { + left = theme.Dimmed.Render("last: " + relativeTime(m.lastFetched)) + } + + remaining := time.Until(m.nextTick).Seconds() + total := m.cfg.PollInterval().Seconds() + bar := components.Countdown(remaining, total, 20) + next := theme.Dimmed.Render(fmt.Sprintf(" next: %ds", int(remaining))) + + hints := footerHints( + keys.Refresh, keys.Menu, keys.OpenPR, keys.Up, keys.Down, keys.Quit, + ) + + top := lipgloss.JoinHorizontal(lipgloss.Left, left, " ", bar, next) + return lipgloss.JoinVertical(lipgloss.Left, top, hints) +} diff --git a/internal/ui/helpers.go b/internal/ui/helpers.go new file mode 100644 index 0000000..a56955b --- /dev/null +++ b/internal/ui/helpers.go @@ -0,0 +1,63 @@ +package ui + +import ( + "strings" + + "github.com/bluegardenproject/github-butler/internal/ui/theme" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/lipgloss" +) + +// pad right-pads/truncates text to a visible width, measuring with +// lipgloss so ANSI sequences and wide runes behave correctly. +func pad(s string, width int) string { + w := lipgloss.Width(s) + if w == width { + return s + } + if w > width { + return truncate(s, width) + } + return s + strings.Repeat(" ", width-w) +} + +// padVisible pads an already-styled string (containing ANSI sequences) to +// `width` visible columns. Uses lipgloss.Width for accurate measurement. +func padVisible(s string, width int) string { + w := lipgloss.Width(s) + if w >= width { + return s + } + return s + strings.Repeat(" ", width-w) +} + +// truncate shortens s so its visible length is at most max, adding an +// ellipsis when it had to cut. Operates on runes, not bytes. +func truncate(s string, max int) string { + if max <= 0 { + return "" + } + r := []rune(s) + if len(r) <= max { + return s + } + if max == 1 { + return "…" + } + return string(r[:max-1]) + "…" +} + +// footerHints renders "key label key label ..." using the shared theme. +func footerHints(bindings ...key.Binding) string { + parts := make([]string, 0, len(bindings)) + for _, b := range bindings { + h := b.Help() + if h.Key == "" { + continue + } + parts = append(parts, + theme.KeyHint.Render(h.Key)+" "+theme.KeyLabel.Render(h.Desc), + ) + } + return strings.Join(parts, " ") +} diff --git a/internal/ui/keys.go b/internal/ui/keys.go new file mode 100644 index 0000000..63dfa35 --- /dev/null +++ b/internal/ui/keys.go @@ -0,0 +1,35 @@ +package ui + +import "github.com/charmbracelet/bubbles/key" + +// keyMap groups every key binding in one place so the help footer and +// handlers stay in sync. +type keyMap struct { + Up key.Binding + Down key.Binding + Select key.Binding + Back key.Binding + Quit key.Binding + Refresh key.Binding + Menu key.Binding + OpenPR key.Binding + AddRepo key.Binding + DelRepo key.Binding + ConfYes key.Binding + ConfNo key.Binding +} + +var keys = keyMap{ + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), + Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), + Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), + Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), + Menu: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "menu")), + OpenPR: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "open in browser")), + AddRepo: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add")), + DelRepo: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")), + ConfYes: key.NewBinding(key.WithKeys("y")), + ConfNo: key.NewBinding(key.WithKeys("n")), +} diff --git a/internal/ui/menu.go b/internal/ui/menu.go new file mode 100644 index 0000000..33a38c9 --- /dev/null +++ b/internal/ui/menu.go @@ -0,0 +1,68 @@ +package ui + +import ( + "github.com/bluegardenproject/github-butler/internal/ui/components" + "github.com/bluegardenproject/github-butler/internal/ui/theme" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type menuItem struct { + label string + target screen +} + +var menuItems = []menuItem{ + {label: "Repositories", target: screenRepos}, + {label: "Settings", target: screenSettings}, + {label: "Back to dashboard", target: screenDashboard}, +} + +func (m Model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) { + km, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch { + case key.Matches(km, keys.Quit): + return m, tea.Quit + case key.Matches(km, keys.Back), key.Matches(km, keys.Menu): + m.screen = screenDashboard + case key.Matches(km, keys.Up): + if m.menuCursor > 0 { + m.menuCursor-- + } + case key.Matches(km, keys.Down): + if m.menuCursor < len(menuItems)-1 { + m.menuCursor++ + } + case key.Matches(km, keys.Select): + target := menuItems[m.menuCursor].target + m.screen = target + switch target { + case screenRepos: + m.reposCursor = 0 + case screenSettings: + m.settingsCursor = 0 + } + } + return m, nil +} + +func (m Model) viewMenu() string { + banner := components.Banner(" MENU ") + + var rows []string + for i, item := range menuItems { + line := " " + item.label + if i == m.menuCursor { + line = theme.SelectedRow.Render(" ▸ " + item.label + " ") + } + rows = append(rows, line) + } + body := theme.Panel.Render(lipgloss.JoinVertical(lipgloss.Left, rows...)) + + hints := footerHints(keys.Up, keys.Down, keys.Select, keys.Back, keys.Quit) + return lipgloss.JoinVertical(lipgloss.Left, banner, body, hints) +} diff --git a/internal/ui/messages.go b/internal/ui/messages.go new file mode 100644 index 0000000..dad724a --- /dev/null +++ b/internal/ui/messages.go @@ -0,0 +1,32 @@ +package ui + +import ( + "time" + + "github.com/bluegardenproject/github-butler/internal/github" +) + +// Shared Bubble Tea messages. Grouped here so any view or command can +// reference them without worrying about import cycles. + +type tickMsg time.Time + +// uiTickMsg is a fast, display-only heartbeat used to animate the +// countdown bar and relative timestamps. It never triggers a fetch. +type uiTickMsg time.Time + +type prsFetchedMsg struct { + PRs []github.PR + Fetched time.Time +} + +type errMsg struct{ Err error } + +func (e errMsg) Error() string { return e.Err.Error() } + +type configSavedMsg struct{} + +type repoValidatedMsg struct { + Slug string + Err error +} diff --git a/internal/ui/repos.go b/internal/ui/repos.go new file mode 100644 index 0000000..fa6037b --- /dev/null +++ b/internal/ui/repos.go @@ -0,0 +1,190 @@ +package ui + +import ( + "fmt" + + "github.com/bluegardenproject/github-butler/internal/github" + "github.com/bluegardenproject/github-butler/internal/ui/components" + "github.com/bluegardenproject/github-butler/internal/ui/theme" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +func (m Model) updateRepos(msg tea.Msg) (tea.Model, tea.Cmd) { + km, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch { + case key.Matches(km, keys.Quit): + return m, tea.Quit + case key.Matches(km, keys.Back): + m.screen = screenMenu + case key.Matches(km, keys.Menu): + m.screen = screenDashboard + case key.Matches(km, keys.Up): + if m.reposCursor > 0 { + m.reposCursor-- + } + case key.Matches(km, keys.Down): + if m.reposCursor < len(m.cfg.Repos)-1 { + m.reposCursor++ + } + case key.Matches(km, keys.AddRepo): + m.screen = screenAddRepo + m.addInput.SetValue("") + m.addInput.Focus() + m.validationErr = "" + m.validating = false + return m, nil + case key.Matches(km, keys.DelRepo): + if len(m.cfg.Repos) > 0 { + m.screen = screenConfirmRemove + } + } + return m, nil +} + +func (m Model) updateAddRepo(msg tea.Msg) (tea.Model, tea.Cmd) { + km, ok := msg.(tea.KeyMsg) + if ok { + switch { + case key.Matches(km, keys.Back): + m.screen = screenRepos + m.addInput.Blur() + m.validating = false + m.validationErr = "" + return m, nil + case km.Type == tea.KeyEnter: + raw := m.addInput.Value() + slug, err := github.ParseRepoRef(raw) + if err != nil { + m.validationErr = err.Error() + return m, nil + } + for _, existing := range m.cfg.Repos { + if existing == slug { + m.validationErr = "already tracked" + return m, nil + } + } + m.validating = true + m.validationErr = "" + return m, validateRepoCmd(m.client, slug) + } + } + var cmd tea.Cmd + m.addInput, cmd = m.addInput.Update(msg) + return m, cmd +} + +func (m Model) handleRepoValidated(msg repoValidatedMsg) (tea.Model, tea.Cmd) { + m.validating = false + if msg.Err != nil { + m.validationErr = msg.Err.Error() + return m, nil + } + m.cfg.Repos = append(m.cfg.Repos, msg.Slug) + m.screen = screenRepos + m.addInput.Blur() + return m, saveConfigCmd(m.cfg) +} + +func (m Model) updateConfirmRemove(msg tea.Msg) (tea.Model, tea.Cmd) { + km, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch { + case key.Matches(km, keys.ConfYes): + if m.reposCursor >= 0 && m.reposCursor < len(m.cfg.Repos) { + m.cfg.Repos = append(m.cfg.Repos[:m.reposCursor], m.cfg.Repos[m.reposCursor+1:]...) + if m.reposCursor >= len(m.cfg.Repos) { + m.reposCursor = len(m.cfg.Repos) - 1 + if m.reposCursor < 0 { + m.reposCursor = 0 + } + } + m.screen = screenRepos + return m, saveConfigCmd(m.cfg) + } + m.screen = screenRepos + case key.Matches(km, keys.ConfNo), key.Matches(km, keys.Back): + m.screen = screenRepos + case key.Matches(km, keys.Menu): + m.screen = screenDashboard + } + return m, nil +} + +func (m Model) viewRepos() string { + banner := components.Banner(" REPOSITORIES ") + + list := m.renderRepoList() + body := theme.Panel.Render(list) + + var extra string + switch m.screen { + case screenAddRepo: + extra = m.renderAddRepo() + case screenConfirmRemove: + if m.reposCursor >= 0 && m.reposCursor < len(m.cfg.Repos) { + extra = components.Confirm(fmt.Sprintf("Remove %q?", m.cfg.Repos[m.reposCursor])) + } + } + + hints := footerHints(keys.AddRepo, keys.DelRepo, keys.Up, keys.Down, keys.Back, keys.Menu, keys.Quit) + + parts := []string{banner, body} + if extra != "" { + parts = append(parts, extra) + } + parts = append(parts, hints) + return lipgloss.JoinVertical(lipgloss.Left, parts...) +} + +func (m Model) renderRepoList() string { + if len(m.cfg.Repos) == 0 { + return theme.Dimmed.Render(" (none — press ") + + theme.KeyHint.Render("a") + + theme.Dimmed.Render(" to add one)") + } + var rows []string + for i, r := range m.cfg.Repos { + line := " " + r + if i == m.reposCursor && m.screen == screenRepos { + line = theme.SelectedRow.Render(" ▸ " + r + " ") + } else if i == m.reposCursor { + line = theme.Accent.Render(" ▸ " + r) + } + rows = append(rows, line) + } + return lipgloss.JoinVertical(lipgloss.Left, rows...) +} + +func (m Model) renderAddRepo() string { + label := theme.PanelTitle.Render("Add repository") + input := m.addInput.View() + hint := theme.Dimmed.Render("Accepts: owner/repo · https://github.com/owner/repo · git@github.com:owner/repo.git") + + var status string + switch { + case m.validating: + status = theme.Pending.Render("Validating with gh api…") + case m.validationErr != "": + status = theme.Fail.Render("Error: " + m.validationErr) + } + + rows := []string{label, input, hint} + if status != "" { + rows = append(rows, status) + } + rows = append(rows, theme.Dimmed.Render("[enter] submit [esc] cancel")) + + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(theme.NeonPink). + Padding(0, 1). + Render(lipgloss.JoinVertical(lipgloss.Left, rows...)) +} diff --git a/internal/ui/settings.go b/internal/ui/settings.go new file mode 100644 index 0000000..cf769fb --- /dev/null +++ b/internal/ui/settings.go @@ -0,0 +1,180 @@ +package ui + +import ( + "fmt" + "strconv" + "strings" + + "github.com/bluegardenproject/github-butler/internal/config" + "github.com/bluegardenproject/github-butler/internal/ui/components" + "github.com/bluegardenproject/github-butler/internal/ui/theme" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// settingItem describes a single editable setting. Each item owns its +// own activation behavior so the list can mix edit-screen items (like +// the poll interval input) with one-shot toggles (like group-by-repo) +// without the caller having to special-case them. +type settingItem struct { + label string + value func(cfg config.Config) string + activate func(m Model) (Model, tea.Cmd) +} + +var settingItems = []settingItem{ + { + label: "Poll interval (seconds)", + value: func(cfg config.Config) string { return strconv.Itoa(cfg.PollIntervalSeconds) }, + activate: openIntervalEditor, + }, + { + label: "Group by repo", + value: func(cfg config.Config) string { return onOff(cfg.GroupByRepo) }, + activate: toggleGroupByRepo, + }, +} + +func onOff(b bool) string { + if b { + return "on" + } + return "off" +} + +// openIntervalEditor transitions into the numeric editor for the poll +// interval, pre-filling the current value. +func openIntervalEditor(m Model) (Model, tea.Cmd) { + m.screen = screenEditInterval + m.intervalInput.SetValue(strconv.Itoa(m.cfg.PollIntervalSeconds)) + m.intervalInput.Focus() + m.validationErr = "" + return m, nil +} + +// toggleGroupByRepo flips the group-by-repo flag, re-orders the PRs +// already on screen so the effect is immediate, and persists the +// change to disk. +func toggleGroupByRepo(m Model) (Model, tea.Cmd) { + m.cfg.GroupByRepo = !m.cfg.GroupByRepo + m.prs = OrderPRs(m.prs, m.cfg.GroupByRepo, m.cfg.Repos) + m.selected = 0 + return m, saveConfigCmd(m.cfg) +} + +func (m Model) updateSettings(msg tea.Msg) (tea.Model, tea.Cmd) { + km, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch { + case key.Matches(km, keys.Quit): + return m, tea.Quit + case key.Matches(km, keys.Back): + m.screen = screenMenu + case key.Matches(km, keys.Menu): + m.screen = screenDashboard + case key.Matches(km, keys.Up): + if m.settingsCursor > 0 { + m.settingsCursor-- + } + case key.Matches(km, keys.Down): + if m.settingsCursor < len(settingItems)-1 { + m.settingsCursor++ + } + case key.Matches(km, keys.Select): + if m.settingsCursor >= 0 && m.settingsCursor < len(settingItems) { + item := settingItems[m.settingsCursor] + if item.activate != nil { + return item.activate(m) + } + } + } + return m, nil +} + +func (m Model) updateEditInterval(msg tea.Msg) (tea.Model, tea.Cmd) { + km, ok := msg.(tea.KeyMsg) + if ok { + switch { + case key.Matches(km, keys.Back): + m.screen = screenSettings + m.intervalInput.Blur() + m.validationErr = "" + return m, nil + case km.Type == tea.KeyEnter: + raw := strings.TrimSpace(m.intervalInput.Value()) + n, err := strconv.Atoi(raw) + if err != nil { + m.validationErr = "must be a whole number" + return m, nil + } + if n < config.MinPollIntervalSeconds || n > config.MaxPollIntervalSeconds { + m.validationErr = fmt.Sprintf("must be between %d and %d", + config.MinPollIntervalSeconds, config.MaxPollIntervalSeconds) + return m, nil + } + m.cfg.PollIntervalSeconds = n + m.screen = screenSettings + m.intervalInput.Blur() + m.validationErr = "" + return m, saveConfigCmd(m.cfg) + } + } + var cmd tea.Cmd + m.intervalInput, cmd = m.intervalInput.Update(msg) + return m, cmd +} + +func (m Model) viewSettings() string { + banner := components.Banner(" SETTINGS ") + + var rows []string + for i, item := range settingItems { + val := theme.Accent.Render(item.value(m.cfg)) + line := fmt.Sprintf(" %s: %s", item.label, val) + if i == m.settingsCursor && m.screen == screenSettings { + line = theme.SelectedRow.Render( + fmt.Sprintf(" ▸ %s: %s ", item.label, item.value(m.cfg)), + ) + } + rows = append(rows, line) + } + body := theme.Panel.Render(lipgloss.JoinVertical(lipgloss.Left, rows...)) + + var extra string + if m.screen == screenEditInterval { + extra = m.renderEditInterval() + } + + hints := footerHints(keys.Up, keys.Down, keys.Select, keys.Back, keys.Menu, keys.Quit) + + parts := []string{banner, body} + if extra != "" { + parts = append(parts, extra) + } + parts = append(parts, hints) + return lipgloss.JoinVertical(lipgloss.Left, parts...) +} + +func (m Model) renderEditInterval() string { + label := theme.PanelTitle.Render("Set poll interval") + input := m.intervalInput.View() + hint := theme.Dimmed.Render(fmt.Sprintf( + "min %d, max %d seconds", + config.MinPollIntervalSeconds, config.MaxPollIntervalSeconds, + )) + + rows := []string{label, input, hint} + if m.validationErr != "" { + rows = append(rows, theme.Fail.Render("Error: "+m.validationErr)) + } + rows = append(rows, theme.Dimmed.Render("[enter] save [esc] cancel")) + + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(theme.NeonPink). + Padding(0, 1). + Render(lipgloss.JoinVertical(lipgloss.Left, rows...)) +} diff --git a/internal/ui/theme/gradient.go b/internal/ui/theme/gradient.go new file mode 100644 index 0000000..76b96a3 --- /dev/null +++ b/internal/ui/theme/gradient.go @@ -0,0 +1,75 @@ +package theme + +import ( + "fmt" + "strconv" + + "github.com/charmbracelet/lipgloss" +) + +// Gradient colors each rune of s by linearly interpolating RGB between +// stops. Zero-width runes (e.g. combining marks) are appended to the +// previous rune's styling so width stays correct. +func Gradient(s string, stops ...lipgloss.Color) string { + runes := []rune(s) + if len(runes) == 0 || len(stops) == 0 { + return s + } + if len(stops) == 1 { + return lipgloss.NewStyle().Foreground(stops[0]).Render(s) + } + + rgbs := make([][3]int, len(stops)) + for i, c := range stops { + rgbs[i] = hexToRGB(string(c)) + } + + total := len(runes) + out := make([]byte, 0, total*20) + segments := len(rgbs) - 1 + + for i, r := range runes { + var t float64 + if total > 1 { + t = float64(i) / float64(total-1) + } + seg := int(t * float64(segments)) + if seg >= segments { + seg = segments - 1 + } + localT := t*float64(segments) - float64(seg) + c := interp(rgbs[seg], rgbs[seg+1], localT) + out = append(out, []byte(lipgloss.NewStyle(). + Foreground(lipgloss.Color(rgbToHex(c))). + Render(string(r)))...) + } + return string(out) +} + +func hexToRGB(h string) [3]int { + if len(h) != 7 || h[0] != '#' { + return [3]int{255, 255, 255} + } + r, _ := strconv.ParseInt(h[1:3], 16, 0) + g, _ := strconv.ParseInt(h[3:5], 16, 0) + b, _ := strconv.ParseInt(h[5:7], 16, 0) + return [3]int{int(r), int(g), int(b)} +} + +func rgbToHex(c [3]int) string { + return fmt.Sprintf("#%02X%02X%02X", c[0], c[1], c[2]) +} + +func interp(a, b [3]int, t float64) [3]int { + if t < 0 { + t = 0 + } + if t > 1 { + t = 1 + } + return [3]int{ + int(float64(a[0]) + (float64(b[0])-float64(a[0]))*t), + int(float64(a[1]) + (float64(b[1])-float64(a[1]))*t), + int(float64(a[2]) + (float64(b[2])-float64(a[2]))*t), + } +} diff --git a/internal/ui/theme/palette.go b/internal/ui/theme/palette.go new file mode 100644 index 0000000..0ae4075 --- /dev/null +++ b/internal/ui/theme/palette.go @@ -0,0 +1,34 @@ +// Package theme centralises colors, styles, and the gradient helper. +// Everything here depends only on Lipgloss so it can be reused by +// unrelated tools. +package theme + +import "github.com/charmbracelet/lipgloss" + +// Neon palette (24-bit truecolor). Lipgloss falls back to the closest +// 256-color match on terminals that can't render truecolor. +var ( + NeonPink = lipgloss.Color("#FF10F0") + NeonCyan = lipgloss.Color("#00F0FF") + NeonMagenta = lipgloss.Color("#FF00FF") + NeonLime = lipgloss.Color("#39FF14") + NeonPurple = lipgloss.Color("#BF00FF") + NeonOrange = lipgloss.Color("#FF6A00") + NeonYellow = lipgloss.Color("#F5FF00") + NeonBlue = lipgloss.Color("#1B03FF") + HotPink = lipgloss.Color("#FF2A6D") + + Black = lipgloss.Color("#000000") + White = lipgloss.Color("#FFFFFF") + Dim = lipgloss.Color("#6C6C80") + DarkBg = lipgloss.Color("#120018") +) + +// TitleStops is the color sequence for the app banner gradient. +var TitleStops = []lipgloss.Color{NeonPink, NeonMagenta, NeonPurple, NeonCyan} + +// CountdownStops colors the "next refresh" progress bar. +var CountdownStops = []lipgloss.Color{NeonPink, NeonPurple, NeonCyan} + +// HeaderStops colors the table header row. +var HeaderStops = []lipgloss.Color{NeonCyan, NeonPink} diff --git a/internal/ui/theme/styles.go b/internal/ui/theme/styles.go new file mode 100644 index 0000000..8c5ab53 --- /dev/null +++ b/internal/ui/theme/styles.go @@ -0,0 +1,90 @@ +package theme + +import "github.com/charmbracelet/lipgloss" + +// Pre-built styles. Views should compose these rather than re-creating +// them inline. +var ( + OuterBorder = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(NeonMagenta). + Padding(0, 1) + + Panel = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(NeonCyan). + Padding(0, 1) + + PanelTitle = lipgloss.NewStyle(). + Foreground(NeonPink). + Bold(true). + MarginBottom(1) + + Dimmed = lipgloss.NewStyle(). + Foreground(Dim) + + Bold = lipgloss.NewStyle(). + Bold(true) + + OK = lipgloss.NewStyle(). + Foreground(NeonLime). + Bold(true) + + Fail = lipgloss.NewStyle(). + Foreground(HotPink). + Bold(true) + + Pending = lipgloss.NewStyle(). + Foreground(NeonYellow) + + Info = lipgloss.NewStyle(). + Foreground(NeonCyan) + + Accent = lipgloss.NewStyle(). + Foreground(NeonMagenta). + Bold(true) + + SelectedRow = lipgloss.NewStyle(). + Foreground(NeonYellow). + Background(NeonPurple). + Bold(true) + + // Chips + DraftChip = lipgloss.NewStyle(). + Foreground(Black). + Background(NeonCyan). + Padding(0, 1). + Bold(true) + + StaleChip = lipgloss.NewStyle(). + Foreground(Black). + Background(NeonOrange). + Padding(0, 1). + Bold(true) + + CodeOwnerChip = lipgloss.NewStyle(). + Foreground(Black). + Background(NeonYellow). + Padding(0, 1) + + SuccessToast = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(NeonLime). + Foreground(NeonLime). + Padding(0, 1). + Bold(true) + + ErrorToast = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(HotPink). + Foreground(HotPink). + Padding(0, 1). + Bold(true) + + KeyHint = lipgloss.NewStyle(). + Foreground(NeonMagenta). + Bold(true) + + KeyLabel = lipgloss.NewStyle(). + Foreground(NeonCyan) +) diff --git a/main.go b/main.go new file mode 100644 index 0000000..6e178df --- /dev/null +++ b/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/bluegardenproject/github-butler/cmd" +) + +// Version is the binary version, set at build time via: +// +// -ldflags "-X main.Version=v1.2.3" +// +// Release Please bumps this on every release via the `extra-files` +// entry in release-please-config.json, so the in-tree default also +// matches the latest tagged release between rebuilds. +var Version = "0.1.0" // x-release-please-version + +// BuildTime is the UTC timestamp the binary was built at, set via: +// +// -ldflags "-X main.BuildTime=2026-05-06T17:00:00Z" +var BuildTime = "unknown" + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + cmd.SetVersion(Version, BuildTime) + + if err := cmd.Run(ctx, os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..48ad041 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "bootstrap-sha": "main", + "primary-branch": "main", + "packages": { + ".": { + "package-name": "github-butler", + "release-type": "go", + "include-v-in-tag": true, + "include-component-in-tag": false, + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance Improvements" }, + { "type": "revert", "section": "Reverts" }, + { "type": "deps", "section": "Dependencies" }, + { "type": "refactor", "section": "Code Refactoring", "hidden": false }, + { "type": "docs", "section": "Documentation", "hidden": false }, + { "type": "chore", "section": "Miscellaneous", "hidden": false }, + { "type": "style", "section": "Styles", "hidden": true }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "build", "section": "Build System", "hidden": true }, + { "type": "ci", "section": "Continuous Integration", "hidden": true } + ], + "extra-files": [ + { + "type": "generic", + "path": "main.go", + "glob": false + } + ] + } + } +} diff --git a/scripts/check-commit-msg.sh b/scripts/check-commit-msg.sh new file mode 100755 index 0000000..58df2e1 --- /dev/null +++ b/scripts/check-commit-msg.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Validates a commit message header against Conventional Commits 1.0.0. +# https://www.conventionalcommits.org/en/v1.0.0/ +# +# Usage: check-commit-msg.sh +# +# Exit 0: header is valid or is a kind we deliberately skip +# (merge / revert / fixup / squash / amend autosquash messages). +# Exit 1: header is invalid; a diagnostic is printed to stderr. +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $(basename "$0") " >&2 + exit 2 +fi + +msg_file="$1" +if [[ ! -r "$msg_file" ]]; then + echo "check-commit-msg: cannot read $msg_file" >&2 + exit 2 +fi + +header="$(grep -v '^#' "$msg_file" | sed -n '1p')" + +case "$header" in + "Merge "*|"Revert \""*|"fixup! "*|"squash! "*|"amend! "*) + exit 0 + ;; +esac + +pattern='^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9._/-]+\))?!?: .+' + +if [[ "$header" =~ $pattern ]]; then + exit 0 +fi + +cat >&2 <()!: + + type: build | chore | ci | docs | feat | fix | perf | refactor | revert | style | test + scope: optional, lower-case (e.g. (ui), (config)) + !: optional, marks a breaking change + subject: required, non-empty + +example: + feat(ui): add per-PR merge-blocker column + +See https://www.conventionalcommits.org/en/v1.0.0/ +EOF + +exit 1 diff --git a/scripts/check-go-fmt.sh b/scripts/check-go-fmt.sh new file mode 100755 index 0000000..e6d9f9e --- /dev/null +++ b/scripts/check-go-fmt.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Rejects commits that would leave Go files unformatted, mirroring the +# CI gofmt gate. Designed to be called from .githooks/pre-commit (with +# the default "staged" mode, i.e. only files about to be committed) and +# from anywhere else with "all" to scan the entire tree. +# +# Usage: +# check-go-fmt.sh # checks staged .go files only +# check-go-fmt.sh staged # same as above +# check-go-fmt.sh all # checks every .go file in the repo +# +# Exit 0: nothing needs formatting (or no .go files in scope). +# Exit 1: at least one file would change under gofmt. +# Exit 2: usage error. +set -euo pipefail + +mode="${1:-staged}" + +files=() +case "$mode" in + staged) + # --diff-filter=ACMR drops deletions. mapfile would be cleaner + # here but is bash 4+, and macOS ships /bin/bash at 3.2; the + # while-read form below is the portable equivalent. + while IFS= read -r f; do + [[ -n "$f" ]] && files+=("$f") + done < <(git diff --cached --name-only --diff-filter=ACMR -- '*.go') + ;; + all) + while IFS= read -r f; do + [[ -n "$f" ]] && files+=("$f") + done < <(git ls-files -- '*.go') + ;; + *) + echo "usage: $(basename "$0") [staged|all]" >&2 + exit 2 + ;; +esac + +if [[ ${#files[@]} -eq 0 ]]; then + exit 0 +fi + +# gofmt -l prints one path per file that would be changed; empty +# output means everything is already formatted. +unformatted="$(gofmt -l "${files[@]}")" +if [[ -z "$unformatted" ]]; then + exit 0 +fi + +cat >&2 <