This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
gh-observer is a GitHub PR check watcher CLI tool that improves on gh pr checks --watch by showing runtime metrics, queue latency, and better handling of startup delays. Built as a Go application with a TUI (Terminal User Interface) using Bubbletea.
This repo uses just for all development tasks:
Every commit must include a Signed-off-by: trailer to satisfy the
Developer Certificate of Origin (DCO) policy. Use git commit -s to add it
automatically. See CONTRIBUTING.md for full details.
just build- Build thegh-observerbinary and install locally as gh extensionjust branch <name>- Create a new feature branch (format:$USER/YYYY-MM-DD-<name>)just pr- Create PR, push changes, and watch checksjust again- Push changes, update PR description, and watch GHAs (most common iterative workflow)just merge- Squash merge PR, delete branch, return to main, and pull latestjust sync- Return to main branch, pull latest, and check status
just prweb- Open current PR in web browserjust pr_update- Update PR description with current commits (done automatically byagain)just pr_checks- Watch GHAs then check for Copilot suggestionsjust claude_review- Claude's latest PR code reviewjust pr_verify- Add or append to Verify section from stdinjust deps-update- Update Go dependencies and tidy go.mod/go.sumjust test2cast <pr>- Record asciinema demo of watching a specific PRjust release_status- Check release workflow status and list binariesjust release_age- Check how long ago the last release wasjust vex_generate- Generate OpenVEX document from govulncheckjust vex_show- Display current VEX documentjust vex_add <vuln>- Add a not_affected VEX statement
just testorgo test ./...- Run all unit testsgo test ./internal/timing/...- Run timing tests onlygo test ./internal/github/...- Run GitHub client tests onlygo test ./internal/debug/...- Run debug logger tests only
Pre-commit hooks enforce quality via .pre-commit-config.yaml:
golangci-lint- Go lintingshellcheck- Shell script lintinggitleaks- Secret scanning- Standard hooks (trailing whitespace, EOF fixes)
Unit tests cover timing calculations, log parsing, history fetching, PR parsing, and TUI display/update logic. The TUI rendering and live GitHub API interactions are tested manually by running just build and pointing the binary at a real PR.
gh-observer uses automated binary building for releases via GitHub Actions and gh-extension-precompile.
To create a new release:
just release v1.0.0This command:
- Runs
gh release create v1.0.0 --generate-notes - Creates the GitHub release with auto-generated release notes
- Pushes the version tag to the repository
- Triggers
.github/workflows/release.ymlvia the tag push
When a version tag (matching v*) is pushed, the release workflow automatically:
-
Builds cross-platform binaries using the
gh-extension-precompilenaming convention (<os>-<arch>, no prefix or version):darwin-amd64- macOS Inteldarwin-arm64- macOS Apple Siliconlinux-386- Linux i386linux-amd64- Linux x86-64linux-arm- Linux ARMlinux-arm64- Linux ARM64freebsd-386- FreeBSD i386freebsd-amd64- FreeBSD x86-64freebsd-arm64- FreeBSD ARM64windows-386.exe- Windows i386windows-amd64.exe- Windows x86-64windows-arm64.exe- Windows ARM64
-
Generates checksums
-
Creates build attestations for supply chain security (verifiable via
gh attestation verify) -
Signs each binary with cosign (keyless signing) producing
.sigand.pemfiles -
Uploads cosign signatures and certificates to the GitHub release
-
Generates SLSA provenance via
slsa-framework/slsa-github-generatorand uploads.intoto.jsonlto the release
The .github/workflows/release.yml workflow:
- Trigger: Push of tags matching
v*pattern - Build: Uses
cli/gh-extension-precompile@v2.1.0withgenerate_attestations: true - Signing: Keyless cosign signing of each binary after build
- Provenance: SLSA Level 3 provenance via
slsa-framework/slsa-github-generator@v2.1.0 - Go Version: Auto-detected from
go.mod(currently 1.26.2) viago_version_fileparameter - Permissions: Requires
contents: write,id-token: write,attestations: write
To test the release workflow without committing to a stable version:
# Create a prerelease tag (tags with hyphens create prereleases automatically)
git tag v0.1.0-rc.1
git push origin v0.1.0-rc.1
# Watch the workflow run
gh run watch
# Verify release assets were created
gh release view v0.1.0-rc.1
# Test installation
gh extension install fini-net/gh-observer
# Verify build attestation (macOS example)
gh attestation verify darwin-arm64 --owner fini-net
# Verify cosign signature (macOS example)
cosign verify-blob darwin-arm64 \
--certificate darwin-arm64.pem \
--signature darwin-arm64.sig \
--certificate-identity=https://github.com/fini-net/gh-observer/.github/workflows/release.yml@refs/tags/v0.1.0-rc.1 \
--certificate-oidc-issuer=https://token.actions.githubusercontent.comAfter running just release, you can verify the workflow completed successfully:
# Check workflow status
just release_status
# Or manually verify
gh release view v1.0.0
gh run list --workflow=release.yml --limit 5Once released, users can install without the Go toolchain:
# Install latest version
gh extension install fini-net/gh-observer
# Install specific version
gh extension install fini-net/gh-observer --pin v1.0.0
# Upgrade to latest
gh extension upgrade gh-observerAll release binaries include cosign signatures and SLSA provenance:
- Cosign signatures: Each binary is signed using keyless (certificate-based) signing via
cosign sign-blob- Produces
.sigsignature files,.pemcertificate files, and.bundlebundle files per binary (e.g.,darwin-arm64.sig,darwin-arm64.pem,darwin-arm64.bundle) - Verifiable via
cosign verify-blob <binary> --certificate <binary>.pem --signature <binary>.sig --certificate-identity=https://github.com/fini-net/gh-observer/.github/workflows/release.yml@refs/tags/<version> --certificate-oidc-issuer=https://token.actions.githubusercontent.com - Also verifiable via
cosign verify-blob <binary> --bundle <binary>.bundle --certificate-identity=https://github.com/fini-net/gh-observer/.github/workflows/release.yml@refs/tags/<version> --certificate-oidc-issuer=https://token.actions.githubusercontent.com
- Produces
- SLSA provenance: Generated using
slsa-framework/slsa-github-generator(v2.1.0)- Produces
.intoto.jsonlprovenance attestation per release - Provides non-forgeable proof of build origin (SLSA Build Level 3)
- Automatically uploaded to the GitHub release as a release asset
- Produces
- Build attestations: Generated by
gh-extension-precompilewithgenerate_attestations: true- Verifiable via
gh attestation verify <binary> --owner fini-net
- Verifiable via
- VEX documents: OpenVEX documents declare which vulnerabilities in dependencies are not exploitable
- Generated from
govulncheckcall-graph analysis viavex/generate-vex.sh - CI workflow
.github/workflows/vex.ymlregenerates weekly and on releases - Manual additions via
just vex_add <vuln_id> - See
SECURITY.mdfor full VEX documentation
- Generated from
The OpenSSF Best Practices requirement states: "When the project has made a release, the project documentation MUST contain instructions to verify the expected identity of the person or process authoring the software release."
Documentation for this is in:
- SECURITY.md - Full "Verifying Release Author Identity" section with methods to verify both the process identity (build attestations, SLSA provenance, workflow run) and the person identity (git commit author + DCO trailer, GitHub release author)
- README.md - User-facing "Verifying the Release Author" subsection under "Verifying Release Assets" with copy-paste commands
All current tags are lightweight (unsigned). Annotated + signed tags would provide GPG-based author verification; see the open issue for that enhancement.
- Keyless cosign signing: Uses Sigstore's keyless signing (certificate-based) instead of GPG or stored keys, avoiding secret management complexity
- SLSA provenance via reusable workflow: Uses the
slsa-framework/slsa-github-generatorgeneric workflow for tamper-proof provenance - Automatic Go version detection: Uses
go_version_file: go.modto stay in sync with project requirements - Standard build process: No custom build scripts needed -
go buildworks perfectly for this project - Complementary workflows: The
just releasecommand creates releases; the GitHub Action builds binaries, signs them, and generates provenance. They work together, not as replacements. - Binary naming convention:
gh-extension-precompilenames binaries as<os>-<arch>(e.g.,darwin-arm64), not<repo>_<version>_<os>-<arch>. The signing and SLSA provenance steps use globs likedarwin-*,linux-*,windows-*that must match this naming pattern. Using the old-style pattern*_darwin-*will fail because no files match it.
gh-observer follows a clean architecture with distinct layers:
- Main entry point (
main.go) - Handles command-line arguments using Cobra, configuration loading, and mode selection (TUI vs snapshot) - GitHub client layer (
internal/github/) - Abstracts GitHub API interactions - TUI layer (
internal/tui/) - Implements Bubbletea model/view/update pattern for interactive mode - Configuration (
internal/config/) - Loads user config from~/.config/gh-observer/config.yaml - Timing utilities (
internal/timing/) - Calculates queue latency, runtime, and formats durations - Debug logging (
internal/debug/) - Structured debug logging viaslog; writes toos.TempDir()/gh-observer-debug/when enabled via--debug/-dflag
The application operates in two modes based on whether stdout is a terminal:
Interactive mode (default when running in a terminal):
- Uses Bubbletea TUI with live updates
- Polls GitHub API every 5s (configurable)
- Shows spinner and real-time status changes
- Automatically quits when all checks complete
- Supports keyboard input (q to quit)
Snapshot mode (when stdout is not a terminal, e.g., in scripts or CI):
- Implemented in
runSnapshot()function inmain.go - Prints a single snapshot of current check status
- Plain text output without colors or TUI
- Exits immediately after printing
- Returns appropriate exit code based on check results
- Useful for scripting:
gh-observer && echo "All checks passed!"
The TUI follows the Elm Architecture pattern (Model-View-Update):
- Model (
internal/tui/model.go) - Application state including PR metadata, check runs, rate limits, and UI state - Init (
internal/tui/update.go) - Initializes the model and kicks off PR info fetch - Update (
internal/tui/update.go) - Message handler that processes:TickMsg- Periodic refresh (every 5s configurable)PRInfoMsg- PR metadata (title, SHA, timestamps)ChecksUpdateMsg- Check run status updatesWorkflowsDiscoveredMsg- Workflow discovery results (run→workflow ID mappings)JobAveragesPartialMsg- Per-workflow history fetch results (averages for one workflow)tea.KeyMsg- Keyboard input (q to quit)
- View (
internal/tui/view.go) - Renders the terminal UI - Display (
internal/tui/display.go) - Column formatting, alignment widths, URL building for check hyperlinks - Styles (
internal/tui/styles.go) - Lipgloss color scheme and styling - Messages (
internal/tui/messages.go) - Custom message types for async operations - Constants (
internal/tui/constants.go) - Tuning thresholds:slowJobThreshold(2m),verySlowJobThreshold(3m),rateBackoffThreshold(10),minRateLimitForFetch(100),historyFetchDelay(10s)
The internal/github/graphql.go module uses GraphQL to efficiently fetch check run data:
Query structure - Follows gh pr checks pattern:
Repository → PullRequest → Commits → StatusCheckRollup → CheckRun
→ CheckSuite → WorkflowRun → Workflow → Name
Key benefits:
- Single API call gets all data (workflow name + check status)
- More efficient than REST API (fewer API calls, less rate limit usage)
- Returns enriched
CheckRunInfowith workflow name included - Supports cursor-based pagination for PRs with more than 100 status contexts
Display format - Check names shown as "Workflow Name / Job Name":
- "CUE Validation / verify"
- "MarkdownLint / lint"
- "Claude Code Review / claude-review"
- "Checkov" (legacy checks without workflow show job name only)
Error annotations - Failed checks display inline error details:
CheckRunInfoincludesSummaryandAnnotationsfieldsAnnotationstruct captures message, path, line number, title, and severity level- First 5 annotations per check are fetched via GraphQL
- Failed checks render a summary line and error box with file paths and messages
The internal/github/history.go module fetches historical job runtimes for ETA estimation using a two-phase approach:
- Phase 1: Discovery (
DiscoverWorkflows()) - Resolves run IDs from current check run URLs to workflow IDs, respecting the incremental cache (knownRunIDToWorkflowID,knownFetchedWorkflowIDs) - Phase 2: Per-workflow fetch (
FetchWorkflowHistory()) - Fetches recent completed runs for each workflow ID in parallel, averaging job durations per job name - The TUI dispatches these phases sequentially:
WorkflowsDiscoveredMsgtriggers parallelFetchWorkflowHistorycalls, with each result delivered as aJobAveragesPartialMsg - Uses incremental caching to avoid redundant API calls across polling cycles
- History fetch is gated by
minRateLimitForFetch(100) and delayed byhistoryFetchDelay(10s) after first check appears - Non-fatal: skips individual failures and returns whatever data was collected
- The legacy monolithic
FetchJobAverages()remains available for snapshot mode
The internal/timing/calculator.go module provides three core metrics:
-
Queue latency - Time from commit push to check start (
QueueLatency())- Calculated as:
check.StartedAt - headCommitTime - Shows how long GitHub took to queue the job
- Calculated as:
-
Runtime - Elapsed time for in-progress checks (
Runtime())- Calculated as:
time.Now() - check.StartedAt - Only for checks with status
in_progress
- Calculated as:
-
Final duration - Total runtime for completed checks (
FinalDuration())- Calculated as:
check.CompletedAt - check.StartedAt
- Calculated as:
The internal/github/ package provides API interaction with both REST and GraphQL:
The application supports running inside jj (Jujutsu) repositories, both colocated and non-colocated:
- Detection (
IsJujutsu()) - Walks up from cwd looking for a.jj/directory - GIT_DIR handling (
SetGITDirForJJ()) - When jj is detected, runsjj git rootto find the internal git directory and setsGIT_DIRongh pr viewcommands. This is necessary becausegh pr viewrelies on git to determine the current branch, and in non-colocated jj repos the git directory is inside.jj/repo/store/git/ - Error messaging - When auto-detection fails in a jj repo, the error message suggests explicit PR number/URL arguments or using
jj git colocation enable - Lazy detection - jj detection and
jj git rootare computed once and cached viasync.Once
This follows the approach recommended in jj's own documentation: GIT_DIR=$(jj git root) gh pr view ...
-
GetToken()- Retrieves GitHub token using:GITHUB_TOKENenvironment variable (first priority)gh auth tokenoutput (fallback)
-
NewClient()- Creates authenticated REST API client for PR metadata -
GetCurrentPRWithRepo()- Auto-detects PR number and owner/repo from current branch (correctly handles forked repos by deriving owner/repo from the PR URL; sets GIT_DIR for jj compatibility) -
GetPRWithRepo(prNumber)- Fetches PR number and owner/repo for an explicit PR number (correctly handles forks; sets GIT_DIR for jj compatibility) -
IsJujutsu()- Detects whether the current working directory is inside a jj (Jujutsu) repository by searching for a.jj/directory -
SetGITDirForJJ(cmd)- Sets the GIT_DIR environment variable on an exec.Cmd when running inside a jj repo, enablinggh pr viewto work in non-colocated jj workspaces -
ParsePRURL(prURL)- Extracts owner, repo, and PR number from a GitHub PR URL (supportshttps://github.com/owner/repo/pull/NNNformat) -
FetchPRInfo()- Retrieves PR metadata (title, SHA, timestamps) via REST API -
FetchCheckRunsGraphQL()- Fetches check runs with workflow names via GraphQL- Uses single GraphQL query for efficiency
- Supports cursor-based pagination for PRs with more than 100 status contexts
- Returns
CheckRunInfowith workflow name, job name, status, timestamps, summary, and annotations
-
FetchCheckRuns()- REST-based fallback for fetching check runs (used internally by snapshot mode) -
FailureConclusion()- Returns true if a conclusion indicates a failed check (failure, timed_out, or action_required) -
ParseTimestamp()- Parses GitHub API timestamps usingTimestampFormat("2006-01-02T15:04:05Z")
User configuration lives in ~/.config/gh-observer/config.yaml:
refresh_interval: 5s # How often to poll GitHub API
enable_links: true # Whether to render hyperlinks in terminal (default: true)
colors:
success: 10 # ANSI 256-color code for completed checks
failure: 9 # ANSI 256-color code for failed checks
running: 11 # ANSI 256-color code for in-progress checks
queued: 8 # ANSI 256-color code for queued checksThe internal/config/config.go module uses Viper with defaults if config doesn't exist. See .config.example.yaml for a reference configuration.
The application returns meaningful exit codes:
- 0 - All checks passed successfully
- 1 - One or more checks failed (failure, timed_out, or action_required)
- 1 - Error during execution (authentication, network, etc.)
Exit code determination happens in internal/tui/update.go:
allChecksComplete()checks if all checks have statuscompleteddetermineExitCode()scans for failure conclusions and returns 1 if any found- The TUI automatically quits when all checks complete
The application tracks GitHub API rate limits:
ChecksUpdateMsgincludesRateLimitRemainingfrom API response- When remaining < 10, the refresh interval triples (
m.refreshInterval * 3) - Default rate limit assumption is 5000 if not available in response
The TUI has special handling for GitHub Actions startup delay (typically 30-90s):
- Displays helpful "Startup Phase" message while waiting for checks
- Shows elapsed time since PR creation
- Only polls for check runs after receiving the head SHA from PR info
git clone https://github.com/fini-net/gh-observer.git
cd gh-observer
just build
./gh-observergo install github.com/fini-net/gh-observer@latest# Auto-detect PR from current branch
gh-observer
# Watch specific PR number
gh-observer 123
# Watch PR on external repository by URL
gh-observer https://github.com/owner/repo/pull/123
# Use in CI pipelines (exits with check status)
gh-observer && echo "All checks passed!"go1.26+ - Go programming languagegh- GitHub CLI (for auth and PR detection)git- Version control
just- Command runner for development tasks
charm.land/bubbletea/v2- TUI framework (Elm Architecture)charm.land/lipgloss/v2- Terminal styling and layoutcharm.land/bubbles/v2- Reusable TUI components (spinner)github.com/google/go-github/v88- GitHub REST API client (PR metadata and log fetching)github.com/shurcooL/githubv4- GitHub GraphQL API client (check runs)github.com/spf13/cobra- CLI framework for command-line argument parsinggithub.com/spf13/viper- Configuration managementgolang.org/x/oauth2- OAuth2 authentication for GitHubgolang.org/x/term- Terminal detection for snapshot vs interactive modegithub.com/muesli/termenv- Terminal color profile detection
- PR detection uses
gh pr viewcommand and parses JSON output - Owner/repo parsing supports both SSH (
git@github.com:owner/repo.git) and HTTPS formats - Check runs fetched via GraphQL for efficiency (single query gets workflow names)
- PR metadata fetched via REST API (simpler for basic PR info)
- GraphQL status/conclusion values normalized to lowercase for consistency
- All timestamps from GitHub API are parsed in RFC3339 format
- The TUI uses a spinner for visual feedback during polling
- Keyboard input is limited to 'q' and 'ctrl+c' for quitting
- Network errors during polling are non-fatal (stored in
m.errbut polling continues) - The application polls every 5s by default, configurable via
refresh_interval - Terminal detection uses
term.IsTerminal(os.Stdout.Fd())to switch between TUI and snapshot modes --quick/-qflag skips fetching historical average runtimes (faster startup, no ETA estimation)--debug/-dflag enables structured debug logging toos.TempDir()/gh-observer-debug/.repo.tomlfile configures repo metadata and feature flags (used by just recipes for Claude/Copilot reviews)