Repository: dhth/prs
Branch: main
Commit: 58e428bb3254
Files: 45
Total size: 125.8 KB
Directory structure:
gitextract_hwcw2gwt/
├── .gitattributes
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ ├── config.yml
│ │ └── feature_request.md
│ ├── dependabot.yml
│ ├── scripts/
│ │ └── get-yamlfmt.sh
│ └── workflows/
│ ├── main.yml
│ ├── pr.yml
│ ├── release.yml
│ ├── scan.yml
│ └── vulncheck.yml
├── .gitignore
├── .golangci.yml
├── .goreleaser.yaml
├── AGENTS.md
├── LICENSE
├── README.md
├── cmd/
│ ├── config.go
│ └── root.go
├── docker-compose.yml
├── go.mod
├── go.sum
├── internal/
│ └── utils/
│ ├── assets/
│ │ └── gruvbox.json
│ ├── markdown.go
│ └── markdown_test.go
├── main.go
├── ui/
│ ├── assets/
│ │ └── help.md
│ ├── cmds.go
│ ├── colors.go
│ ├── gh.go
│ ├── initial.go
│ ├── model.go
│ ├── msgs.go
│ ├── navigation.go
│ ├── pr_delegate.go
│ ├── prtl_delegate.go
│ ├── render_helpers.go
│ ├── repo_delegate.go
│ ├── styles.go
│ ├── types.go
│ ├── ui.go
│ ├── update.go
│ ├── utils.go
│ └── view.go
└── yamlfmt.yml
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitattributes
================================================
* linguist-detectable=false
*.go linguist-detectable=true
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help improve prs
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**Setup**
Please complete the following information along with version numbers, if applicable.
- `prs` version or the git commit that you've built `prs` on
- OS [e.g. Ubuntu, macOS]
- Shell [e.g. zsh, fish]
- Terminal Emulator [e.g. kitty, iterm]
- Terminal Multiplexer [e.g. tmux]
- Locale [e.g. en_US.UTF-8, zh_CN.UTF-8, etc.]
**To Reproduce**
Steps to reproduce the behavior:
1. ...
2. ...
3. Error occurs
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
Add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.
================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: false
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for prs
title: ''
labels: enhancement
assignees: ''
---
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "monthly"
groups:
patch-updates:
update-types: ["patch"]
minor-updates:
update-types: ["minor"]
labels:
- "dependencies"
commit-message:
prefix: "build"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
labels:
- "dependencies"
commit-message:
prefix: "ci"
================================================
FILE: .github/scripts/get-yamlfmt.sh
================================================
#!/usr/bin/env bash
set -e
if [ $# -ne 3 ]; then
echo "Usage: $0 "
echo "eg: $0 Linux x86_64 0.13.0"
exit 1
fi
OS="$1"
ARCH="$2"
VERSION="$3"
cwd=$(pwd)
temp_dir=$(mktemp -d)
if [ ! -e ${temp_dir} ]; then
echo "Failed to create temporary directory."
exit 1
fi
cd $temp_dir
curl -sSLO "https://github.com/google/yamlfmt/releases/download/v${VERSION}/yamlfmt_${VERSION}_${OS}_${ARCH}.tar.gz"
curl -sSLO "https://github.com/google/yamlfmt/releases/download/v${VERSION}/checksums.txt"
sha256sum --ignore-missing -c checksums.txt
tar -xzf "yamlfmt_${VERSION}_${OS}_${ARCH}.tar.gz" -C ${temp_dir}/
cd $cwd
cp "${temp_dir}/yamlfmt" .
rm -r ${temp_dir}
================================================
FILE: .github/workflows/main.yml
================================================
name: main
on:
push:
branches:
- 'main'
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
code: ${{ steps.filter.outputs.code }}
deps: ${{ steps.filter.outputs.deps }}
release: ${{ steps.filter.outputs.release }}
workflows: ${{ steps.filter.outputs.workflows }}
yml: ${{ steps.filter.outputs.yml }}
steps:
- name: Checkout
uses: actions/checkout@v6
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
code:
- "cmd/**"
- "internal/**"
- "ui/**"
- "**/*.go"
- "go.*"
- ".golangci.yml"
- "main.go"
- ".github/actions/**"
- ".github/workflows/main.yml"
deps:
- "go.mod"
- "go.sum"
- ".github/workflows/main.yml"
release:
- ".goreleaser.yaml"
- ".github/workflows/main.yml"
workflows:
- ".github/workflows/**.yml"
yml:
- "**.yml"
- "**.yaml"
lint:
needs: changes
if: ${{ needs.changes.outputs.code == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dhth/composite-actions/.github/actions/lint-go@main
build:
needs: changes
if: ${{ needs.changes.outputs.code == 'true' }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: go build
run: go build -v ./...
test:
needs: changes
if: ${{ needs.changes.outputs.code == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: go test
run: go test -v ./...
lint-yaml:
needs: changes
if: ${{ needs.changes.outputs.yml == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- uses: dhth/composite-actions/.github/actions/lint-yaml@main
lint-workflows:
needs: changes
if: ${{ needs.changes.outputs.workflows == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- uses: dhth/composite-actions/.github/actions/lint-actions@main
release-check:
needs: changes
if: ${{ needs.changes.outputs.release == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Release check
uses: goreleaser/goreleaser-action@v7
with:
version: 'v2.9.0'
args: check
vulncheck:
needs: changes
if: ${{ needs.changes.outputs.deps == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: govulncheck
run: govulncheck ./...
================================================
FILE: .github/workflows/pr.yml
================================================
name: pr
on:
pull_request:
jobs:
changes:
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
code: ${{ steps.filter.outputs.code }}
deps: ${{ steps.filter.outputs.deps }}
release: ${{ steps.filter.outputs.release }}
workflows: ${{ steps.filter.outputs.workflows }}
yml: ${{ steps.filter.outputs.yml }}
steps:
- name: Checkout
uses: actions/checkout@v6
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
code:
- "cmd/**"
- "internal/**"
- "ui/**"
- "**/*.go"
- "go.*"
- ".golangci.yml"
- "main.go"
- ".github/actions/**"
- ".github/workflows/pr.yml"
deps:
- "go.mod"
- "go.sum"
- ".github/workflows/pr.yml"
release:
- ".goreleaser.yaml"
- ".github/workflows/pr.yml"
workflows:
- ".github/workflows/**.yml"
yml:
- "**.yml"
- "**.yaml"
lint:
needs: changes
if: ${{ needs.changes.outputs.code == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dhth/composite-actions/.github/actions/lint-go@main
build:
needs: changes
if: ${{ needs.changes.outputs.code == 'true' }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: go build
run: go build -v ./...
test:
needs: changes
if: ${{ needs.changes.outputs.code == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: go test
run: go test -v ./...
lint-yaml:
needs: changes
if: ${{ needs.changes.outputs.yml == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- uses: dhth/composite-actions/.github/actions/lint-yaml@main
lint-workflows:
needs: changes
if: ${{ needs.changes.outputs.workflows == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- uses: dhth/composite-actions/.github/actions/lint-actions@main
release-check:
needs: changes
if: ${{ needs.changes.outputs.release == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Release check
uses: goreleaser/goreleaser-action@v7
with:
version: 'v2.9.0'
args: check
vulncheck:
needs: changes
if: ${{ needs.changes.outputs.deps == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: govulncheck
run: govulncheck ./...
================================================
FILE: .github/workflows/release.yml
================================================
name: release
on:
push:
tags:
- 'v*'
permissions:
id-token: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Install Cosign
uses: sigstore/cosign-installer@v3
with:
cosign-release: 'v2.5.0'
- name: Release Binaries
uses: goreleaser/goreleaser-action@v7
with:
version: 'v2.9.0'
args: release --clean
env:
GITHUB_TOKEN: ${{secrets.GH_PAT}}
================================================
FILE: .github/workflows/scan.yml
================================================
name: scan
on:
workflow_dispatch:
schedule:
- cron: '0 8 10 * *'
jobs:
virus-total:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Build Binaries
uses: goreleaser/goreleaser-action@v7
with:
version: 'v2.9.0'
args: build --snapshot
- name: List binaries
run: |
ls -lh ./dist/prs_*/prs
- uses: dhth/composite-actions/.github/actions/scan-files@main
with:
files: './dist/prs_*/prs'
vt-api-key: ${{ secrets.VT_API_KEY }}
================================================
FILE: .github/workflows/vulncheck.yml
================================================
name: vulncheck
on:
workflow_dispatch:
schedule:
- cron: '0 2 * * 2,6'
jobs:
vulncheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: govulncheck
run: govulncheck ./...
================================================
FILE: .gitignore
================================================
prs
.quickrun
cosign.key
justfile
debug.log
================================================
FILE: .golangci.yml
================================================
version: "2"
linters:
enable:
- errname
- errorlint
- goconst
- nilerr
- prealloc
- predeclared
- revive
- testifylint
- thelper
- unconvert
- usestdlibvars
- wastedassign
settings:
revive:
rules:
- name: blank-imports
- name: context-as-argument
arguments:
- allowTypesBefore: '*testing.T'
- name: context-keys-type
- name: dot-imports
- name: empty-block
- name: error-naming
- name: error-return
- name: error-strings
- name: errorf
- name: exported
- name: if-return
- name: increment-decrement
- name: indent-error-flow
- name: package-comments
- name: range
- name: receiver-naming
- name: redefines-builtin-id
- name: superfluous-else
- name: time-naming
- name: unexported-return
- name: unreachable-code
- name: unused-parameter
- name: var-declaration
- name: unnecessary-stmt
- name: confusing-naming
- name: unused-receiver
- name: unhandled-error
arguments:
- fmt.Print
- fmt.Println
- fmt.Printf
- fmt.Fprintf
- fmt.Fprint
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gofumpt
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
================================================
FILE: .goreleaser.yaml
================================================
version: 2
release:
draft: true
before:
hooks:
- go mod tidy
- go generate ./...
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- darwin
goarch:
- amd64
- arm64
signs:
- cmd: cosign
signature: "${artifact}.sig"
certificate: "${artifact}.pem"
args:
- "sign-blob"
- "--oidc-issuer=https://token.actions.githubusercontent.com"
- "--output-certificate=${certificate}"
- "--output-signature=${signature}"
- "${artifact}"
- "--yes"
artifacts: checksum
brews:
- name: prs
repository:
owner: dhth
name: homebrew-tap
directory: Formula
license: MIT
homepage: "https://github.com/dhth/prs"
description: "Stay updated on pull requests from your terminal"
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^ci:"
================================================
FILE: AGENTS.md
================================================
# AGENTS.md
This file provides guidance to AI coding agents when working with code in this repository.
## Project Overview
`prs` is a Go terminal UI application for browsing GitHub pull requests. It uses the Bubble Tea TUI framework with GitHub GraphQL API integration via `gh` CLI authentication.
## Common Commands
```bash
just build # go build -ldflags='-s -w' .
just run # go run .
just install # go install -ldflags='-s -w' .
just lint # golangci-lint run
just fmt # gofumpt -w .
```
Note: always use `just` to run commands.
## Architecture
The app follows the standard **Bubble Tea** (Elm-architecture) pattern: `Model` → `Update(msg)` → `View()` cycle.
**Entry flow**: `main.go` → `cmd.Execute()` (Cobra CLI) → `ui.RenderUI()` → `tea.NewProgram(InitialModel())`
### Key packages
- **`cmd/`** — Cobra CLI setup, config loading (Viper). Config priority: flags > env vars (`PRS_*`) > config file.
- **`ui/`** — All TUI logic, split by concern:
- `model.go` — Central Bubble Tea `Model` struct (state)
- `update.go` — Event handling (largest file)
- `view.go` — View rendering
- `cmds.go` — Async `tea.Cmd` functions (GitHub API calls)
- `msgs.go` — Message types passed through Bubble Tea
- `gh.go` — GitHub GraphQL queries
- `types.go` — GraphQL query/response type definitions
- `navigation.go` — View/section navigation with back-stack (`activePane`, `lastPane`, `secondLastActivePane`)
- `styles.go` / `colors.go` — Lipgloss terminal styling
- `render_helpers.go` — Display formatting helpers
- **`internal/utils/`** — Markdown rendering with Glamour (embedded Gruvbox theme)
### View system
Six panes managed by `activePane` enum: `repoListView`, `prListView`, `prDetailsView`, `prTLListView`, `prTLItemDetailView`, `helpView`.
PR details has sub-sections: metadata, description, checks, references, files, commits, comments.
### Caching
PR details and timeline data are cached in maps keyed by `"owner/repo:number"` to avoid redundant API calls.
## Linting
Uses golangci-lint v2 with `gofumpt` formatter and `revive` linter (among others). See `.golangci.yml` for full config.
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2024-2026 Dhruv Thakur
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
prs
`prs` lets you stay updated on pull requests from your terminal.
[source video](https://youtu.be/H81ru9cQhDo)
🤔 Motivation
---
For my day job as a tech lead, I need to stay updated on several PRs, and my
hope is that `prs` will let me do that faster than the Github web UI (or other
tools for that matter).
💾 Installation
---
**homebrew**:
```sh
brew install dhth/tap/prs
```
**go**:
```sh
go install github.com/dhth/prs@latest
```
Or get the binaries directly from a [release][3]. Read more about verifying the
authenticity of released artifacts [here](#-verifying-release-artifacts).
🔑 Authentication
---
You can have `prs` make authenticated calls to GitHub on your behalf in either
of two ways:
- Have an authenticated instance of [gh](https://github.com/cli/cli) available
(recommended).
- Provide a valid Github token via `$GH_TOKEN`.
⚡️ Usage
---
`prs` has two modes:
- **Query mode** (default): lets you search PRs based on a query you provide (based
on github's [search
syntax](https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests))
- **Repos mode**: let you pick a repository from a predefined list
### Query Mode
```shell
prs --query='type:pr repo:neovim/neovim state:open label:lua linked:issue'
# view open PRs where you're the author
prs -q 'type:pr author:@me state:open'
# view open PRs where a review has been requested from you
PRS_QUERY='type:pr user-review-requested:@me state:open' prs
# read query from prs' config file
prs
```
### Repos Mode
```shell
prs --mode=repos --repos='dhth/prs,dhth/omm,dhth/hours'
PRS_REPOS='dhth/prs,dhth/omm,dhth/hours' prs --mode=repos
# read repos from prs' config file
prs -m repos
```
🛠️ Configuration
---
`prs` accepts configuration from any of the following:
- Command line flags (run `prs -h` for details)
- Environment variables (eg. `PRS_QUERY`)
- `prs`'s config file, which looks like this:
```yaml
num: 20
repos:
- dhth/omm
- dhth/hours
- dhth/prs
- neovim/neovim
- junegunn/fzf
- BurntSushi/ripgrep
- charmbracelet/bubbletea
- goreleaser/goreleaser
- dandavison/delta
query: 'type:pr repo:neovim/neovim state:open label:lua linked:issue'
```
For every configuration property, the order of priority is: `flag >>
environment variables >> config file`, ie, flags take the highest priority.
**[`^ back to top ^`](#prs)**
Screenshots
---
### PR List View

### PR Timeline List View

### PR Timeline Item Detail View

### PR Details View




**[`^ back to top ^`](#prs)**
Keyboard Shortcuts
---
### General
```text
q/esc/ctrl+c go back
Q quit from anywhere
? Open Help View
d Open PR Details View
ctrl+v Show PR details using gh
```
### PR List View
```text
Indicators for current review decision:
± implies CHANGES_REQUESTED
🟡 implies REVIEW_REQUIRED
✅ implies APPROVED
⏎/tab/shift+tab/2 Switch focus to PR Timeline View
ctrl+s Switch focus to Repo List View (when --mode=repos)
ctrl+d Show PR diff
ctrl+r Reload PR list
ctrl+b Open PR in browser
```
### PR Details View
```text
h/N/← Go to previous section
l/n/→ Go to next section
1/2/3... Go to specific section
J/] Go to next PR
K/[ Go to previous PR
d Go back to last view
ctrl+b Open PR in browser
```
### Timeline List View
```text
tab/shift+tab/1 Switch focus to PR List View
⏎/3 Show details for PR timeline item (when applicable)
ctrl+d Show PR diff
ctrl+b Open timeline item in browser
ctrl+r Reload PR timeline
```
### Timeline Item Detail View
```text
1 Switch focus to PR List View
2 Switch focus to PR Timeline List View
ctrl+d Show PR diff
ctrl+b Open timeline item in browser
h/N/← Go to previous section
l/n/→ Go to next section
```
### 🔐 Verifying release artifacts
In case you get the `prs` binary directly from a [release][3], you may want to
verify its authenticity. Checksums are applied to all released artifacts, and
the resulting checksum file is signed using
[cosign](https://docs.sigstore.dev/cosign/installation/).
Steps to verify (replace the version in the commands listed with the one you
want):
1. Download the following files from the release:
- prs_1.0.0_checksums.txt
- prs_1.0.0_checksums.txt.pem
- prs_1.0.0_checksums.txt.sig
2. Verify the signature:
```shell
cosign verify-blob prs_1.0.0_checksums.txt \
--certificate prs_1.0.0_checksums.txt.pem \
--signature prs_1.0.0_checksums.txt.sig \
--certificate-identity-regexp 'https://github\.com/dhth/prs/\.github/workflows/.+' \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"
```
3. Download the compressed archive you want, and validate its checksum:
```shell
curl -sSLO https://github.com/dhth/prs/releases/download/v1.0.0/prs_1.0.0_linux_amd64.tar.gz
sha256sum --ignore-missing -c prs_1.0.0_checksums.txt
```
3. If checksum validation goes through, uncompress the archive:
```shell
tar -xzf prs_1.0.0_linux_amd64.tar.gz
./prs
# profit!
```
Acknowledgements
---
`prs` is built using [bubbletea][1], and released via [goreleaser][2].
[1]: https://github.com/charmbracelet/bubbletea
[2]: https://github.com/goreleaser/goreleaser
[3]: https://github.com/dhth/prs/releases
**[`^ back to top ^`](#prs)**
================================================
FILE: cmd/config.go
================================================
package cmd
import (
"os"
"os/user"
"strings"
)
func expandTilde(path string) string {
if strings.HasPrefix(path, "~") {
usr, err := user.Current()
if err != nil {
os.Exit(1)
}
return strings.Replace(path, "~", usr.HomeDir, 1)
}
return path
}
================================================
FILE: cmd/root.go
================================================
package cmd
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"
"time"
ghapi "github.com/cli/go-gh/v2/pkg/api"
"github.com/dhth/prs/ui"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
)
const (
envPrefix = "PRS"
author = "@dhth"
projectHomePage = "https://github.com/dhth/prs"
issuesURL = "https://github.com/dhth/prs/issues"
configFileName = "prs/prs.yml"
defaultSearchQuery = "type:pr author:@me sort:updated-desc state:open"
defaultPRNum = 20
maxPRNum = 50
)
var (
errCouldntGetHomeDir = errors.New("couldn't get home directory")
errCouldntGetConfigDir = errors.New("couldn't get config directory")
errModeIncorrect = errors.New("incorrect mode provided")
errConfigFileDoesntExist = errors.New("config file does not exist")
errNoReposProvided = errors.New("no repos were provided")
errIncorrectRepoProvided = errors.New("incorrect repo provided")
errCouldntSetupGithubClient = errors.New("couldn't set up a Github Client")
)
var reportIssueMsg = fmt.Sprintf("Let %s know about this error via %s.", author, issuesURL)
func Execute(version string) error {
rootCmd, err := NewRootCommand(version)
if err != nil {
fmt.Printf("Error: %s\n", err.Error())
switch {
case errors.Is(err, errCouldntGetHomeDir), errors.Is(err, errCouldntGetConfigDir):
fmt.Printf(`
This is a fatal error; use --config-path to specify config file path manually.
%s
`, reportIssueMsg)
}
return err
}
err = rootCmd.Execute()
if errors.Is(err, errCouldntSetupGithubClient) {
fmt.Printf(`
If the error is due to misconfigured authentication, you can fix that by either of the following:
- Provide a valid Github token via $GH_TOKEN
- Have an authenticated instance of gh (https://github.com/cli/cli) available
`)
}
return err
}
func NewRootCommand(version string) (*cobra.Command, error) {
var (
configFilePath string
configPathFull string
mode ui.Mode
modeInp string
repoStrs []string
repos []ui.Repo
searchQuery string
ghClient *ghapi.GraphQLClient
prNum int
)
rootCmd := &cobra.Command{
Use: "prs",
Short: "prs lets you stay updated on pull requests from your terminal",
Long: fmt.Sprintf(`prs lets you stay updated on pull requests from your terminal.
Use it to query for specific pull requests based on a filter query (using
Github's search syntax), or have it let you pick a repository from a predefined
list.
Examples:
$ prs --query='type:pr repo:neovim/neovim state:open label:lua linked:issue'
$ prs -q 'type:pr author:@me state:open'
$ PRS_QUERY='type:pr user-review-requested:@me state:open' prs
$ prs # will read query from config file
$ prs --mode=repos --repos='dhth/prs,dhth/omm,dhth/hours'
$ PRS_REPOS='dhth/prs,dhth/omm,dhth/hours' prs --mode=repos
$ prs -m repos # will read repos from config file
Project home page: %s
`, projectHomePage),
Args: cobra.MaximumNArgs(0),
SilenceUsage: true,
Version: version,
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
configPathFull = expandTilde(configFilePath)
if filepath.Ext(configPathFull) != ".yml" {
return errConfigFileDoesntExist
}
_, err := os.Stat(configPathFull)
fl := cmd.Flags()
if fl != nil {
cf := fl.Lookup("config-path")
if cf != nil && cf.Changed && errors.Is(err, fs.ErrNotExist) {
return errConfigFileDoesntExist
}
}
var v *viper.Viper
v, err = initializeConfig(cmd, configPathFull)
if err != nil {
return err
}
if prNum > maxPRNum {
prNum = maxPRNum
}
switch modeInp {
case "repos":
mode = ui.RepoMode
case "query":
mode = ui.QueryMode
default:
return errModeIncorrect
}
if mode == ui.RepoMode {
var reposToUse []string
// pretty ugly hack to get around the fact that
// v.GetStringSlice("repos") always seems to prioritize the config file
if len(repoStrs) > 0 && len(repoStrs[0]) > 0 && !strings.HasPrefix(repoStrs[0], "[") {
reposToUse = repoStrs
} else {
reposToUse = v.GetStringSlice("repos")
}
if len(reposToUse) == 0 {
return errNoReposProvided
}
for _, r := range reposToUse {
repoEls := strings.Split(r, "/")
// TODO: there can be more validations done here, maybe regex based
if len(repoEls) != 2 {
return fmt.Errorf("%w: %s", errIncorrectRepoProvided, r)
}
repos = append(repos, ui.Repo{
Owner: strings.TrimSpace(repoEls[0]),
Name: strings.TrimSpace(repoEls[1]),
})
}
}
opts := ghapi.ClientOptions{
EnableCache: true,
CacheTTL: time.Second * 30,
Timeout: 8 * time.Second,
}
ghClient, err = ghapi.NewGraphQLClient(opts)
if err != nil {
return fmt.Errorf("%w: %s", errCouldntSetupGithubClient, err.Error())
}
return nil
},
RunE: func(_ *cobra.Command, _ []string) error {
config := ui.Config{
PRCount: prNum,
Repos: repos,
Query: &searchQuery,
}
return ui.RenderUI(ghClient, config, mode)
},
}
rootCmd.SetVersionTemplate(`{{with .Name}}{{printf "%s " .}}{{end}}{{printf "%s" .Version}}
`)
ros := runtime.GOOS
userCfgDir, err := os.UserConfigDir()
if err != nil {
return nil, fmt.Errorf("%w: %s", errCouldntGetConfigDir, err.Error())
}
var defaultConfigFilePath string
switch ros {
case "linux", "windows":
defaultConfigFilePath = filepath.Join(userCfgDir, configFileName)
default:
// to use ~/.config instead of $HOME/Library/Application Support
hd, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("%w: %s", errCouldntGetHomeDir, err.Error())
}
defaultConfigFilePath = filepath.Join(hd, ".config", configFileName)
}
rootCmd.Flags().StringVarP(&configFilePath, "config-path", "c", defaultConfigFilePath, "location of prs's config file")
rootCmd.Flags().StringVarP(&modeInp, "mode", "m", "query", "mode to run prs in; values: query, repos")
rootCmd.Flags().StringVarP(&searchQuery, "query", "q", defaultSearchQuery, "query to search PRs for")
rootCmd.Flags().IntVarP(&prNum, "num", "n", defaultPRNum, "number of PRs to fetch")
rootCmd.Flags().StringSliceVarP(&repoStrs, "repos", "r", nil, "comma separated list of repos to use for repo mode")
rootCmd.CompletionOptions.DisableDefaultCmd = true
return rootCmd, nil
}
func initializeConfig(cmd *cobra.Command, configFile string) (*viper.Viper, error) {
v := viper.New()
v.SetConfigName(filepath.Base(configFile))
v.SetConfigType("yaml")
v.AddConfigPath(filepath.Dir(configFile))
err := v.ReadInConfig()
if err != nil && !errors.As(err, &viper.ConfigFileNotFoundError{}) {
return v, err
}
v.SetEnvPrefix(envPrefix)
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
v.AutomaticEnv()
err = bindFlags(cmd, v)
if err != nil {
return v, err
}
return v, nil
}
func bindFlags(cmd *cobra.Command, v *viper.Viper) error {
var err error
cmd.Flags().VisitAll(func(f *pflag.Flag) {
if !f.Changed && v.IsSet(f.Name) {
val := v.Get(f.Name)
fErr := cmd.Flags().Set(f.Name, fmt.Sprintf("%v", val))
if fErr != nil {
err = fErr
return
}
}
})
return err
}
================================================
FILE: docker-compose.yml
================================================
version: '3.8'
# to test workings on linux
services:
prs-dev:
image: golang:1.23-alpine
volumes:
- .:/go/src/app
working_dir: /go/src/app
command: sleep infinity
================================================
FILE: go.mod
================================================
module github.com/dhth/prs
go 1.26.3
require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.6
charm.land/lipgloss/v2 v2.0.3
github.com/charmbracelet/glamour v1.0.0
github.com/cli/go-gh/v2 v2.13.0
github.com/cli/shurcooL-graphql v0.0.4
github.com/dustin/go-humanize v1.0.1
github.com/muesli/termenv v0.16.0
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
)
require (
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250812135814-932da4e322f4 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/cli/safeexec v1.0.1 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/henvic/httpretty v0.1.4 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/thlib/go-timezone-local v0.0.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.13 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
================================================
FILE: go.sum
================================================
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
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/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
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/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/slice v0.0.0-20250812135814-932da4e322f4 h1:00G7Z522s/WiCooTYFKATlN5Gjn64miEheomXCPwRcU=
github.com/charmbracelet/x/exp/slice v0.0.0-20250812135814-932da4e322f4/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
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/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/cli/go-gh/v2 v2.13.0 h1:jEHZu/VPVoIJkciK3pzZd3rbT8J90swsK5Ui4ewH1ys=
github.com/cli/go-gh/v2 v2.13.0/go.mod h1:Us/NbQ8VNM0fdaILgoXSz6PKkV5PWaEzkJdc9vR2geM=
github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00=
github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
github.com/cli/shurcooL-graphql v0.0.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJu2GuY=
github.com/cli/shurcooL-graphql v0.0.4/go.mod h1:3waN4u02FiZivIV+p1y4d0Jo1jc6BViMA73C+sZo2fk=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/henvic/httpretty v0.1.4 h1:Jo7uwIRWVFxkqOnErcoYfH90o3ddQyVrSANeS4cxYmU=
github.com/henvic/httpretty v0.1.4/go.mod h1:Dn60sQTZfbt2dYsdUSNsCljyF4AfdqnuJFDLJA1I4AM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.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-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
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/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
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/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/thlib/go-timezone-local v0.0.7 h1:fX8zd3aJydqLlTs/TrROrIIdztzsdFV23OzOQx31jII=
github.com/thlib/go-timezone-local v0.0.7/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI=
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=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
================================================
FILE: internal/utils/assets/gruvbox.json
================================================
{
"document": {
"block_prefix": "",
"block_suffix": "",
"color": "#fbf1c7",
"margin": 2
},
"block_quote": {
"indent": 1,
"indent_token": "┃ "
},
"paragraph": {},
"list": {
"level_indent": 2
},
"heading": {
"block_suffix": "\n",
"color": "#fe8019",
"bold": true
},
"h1": {
"prefix": "# ",
"suffix": "",
"color": "#d3869b",
"bold": true
},
"h2": {
"prefix": "## ",
"color": "#83a598"
},
"h3": {
"prefix": "### ",
"color": "#83a598"
},
"h4": {
"prefix": "#### ",
"color": "#83a598"
},
"h5": {
"prefix": "",
"color": "#fabd2f"
},
"h6": {
"prefix": "",
"color": "#8ec07c",
"bold": false
},
"text": {},
"strikethrough": {
"crossed_out": true
},
"emph": {
"color": "#83a598",
"italic": true,
"prefix": " ",
"suffix": " "
},
"strong": {
"color": "#fe8019",
"bold": true
},
"hr": {
"color": "#928374",
"format": "\n--------\n"
},
"item": {
"block_prefix": "• "
},
"enumeration": {
"block_prefix": ". "
},
"task": {
"ticked": "[✓] ",
"unticked": "[ ] "
},
"link": {
"color": "#83a598",
"underline": true
},
"link_text": {
"color": "#fabd2f",
"bold": true
},
"image": {
"color": "132",
"underline": true
},
"image_text": {
"color": "245",
"format": "Image: {{.text}} →"
},
"code": {
"color": "#fabd2f",
"bold": false
},
"code_block": {
"color": "244",
"chroma": {
"text": {
"color": "#fbf1c7"
},
"error": {
"color": "#282828",
"background_color": "#fb4934"
},
"comment": {
"color": "#928374"
},
"comment_preproc": {
"color": "#8ec07c"
},
"keyword": {
"color": "#fe8019"
},
"keyword_reserved": {
"color": "#8ec07c"
},
"keyword_namespace": {
"color": "#d3869b"
},
"keyword_type": {
"color": "#fabd2f"
},
"operator": {
"color": "#fe8019"
},
"punctuation": {
"color": "#928374"
},
"name": {
"color": "#ebdbb2"
},
"name_builtin": {
"color": "#fabd2f"
},
"name_tag": {
"color": "#fb4934"
},
"name_attribute": {
"color": "#b8bb26"
},
"name_class": {
"color": "#fe8019"
},
"name_constant": {
"color": "#d3869b"
},
"name_decorator": {
"color": "#d3869b"
},
"name_exception": {
"color": "#fb4934"
},
"name_function": {
"color": "#fabd2f"
},
"name_other": {},
"literal": {},
"literal_number": {
"color": "#fabd2f"
},
"literal_date": {},
"literal_string": {
"color": "#b8bb26"
},
"literal_string_escape": {
"color": "#83a598"
},
"generic_deleted": {
"color": "#fb4934"
},
"generic_emph": {
"color": "#83a598",
"italic": true
},
"generic_inserted": {
"color": "#b8bb26"
},
"generic_strong": {
"color": "#ebdbb2",
"bold": true
},
"generic_subheading": {
"color": "#b8bb26"
},
"background": {
"background_color": "#282828"
}
}
},
"table": {
"center_separator": "┼",
"column_separator": "│",
"row_separator": "─"
},
"definition_list": {},
"definition_term": {},
"definition_description": {
"block_prefix": "\n🠶 "
},
"html_block": {},
"html_span": {}
}
================================================
FILE: internal/utils/markdown.go
================================================
package utils
import (
_ "embed"
"github.com/charmbracelet/glamour"
"github.com/muesli/termenv"
)
//go:embed assets/gruvbox.json
var glamourJSONBytes []byte
func GetMarkDownRenderer(wrap int) (*glamour.TermRenderer, error) {
return glamour.NewTermRenderer(
glamour.WithStylesFromJSONBytes(glamourJSONBytes),
glamour.WithColorProfile(termenv.TrueColor),
glamour.WithWordWrap(wrap),
)
}
================================================
FILE: internal/utils/markdown_test.go
================================================
package utils
import (
"encoding/json"
"testing"
"github.com/charmbracelet/glamour"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetGlamourStyleFromFile(t *testing.T) {
gotOption := glamour.WithStylesFromJSONBytes(glamourJSONBytes)
renderer, err := glamour.NewTermRenderer(gotOption)
require.NoError(t, err)
assert.NotNil(t, renderer)
_, err = renderer.Render("a")
assert.NoError(t, err)
}
func TestGlamourStylesFileIsValid(t *testing.T) {
got := json.Valid(glamourJSONBytes)
assert.True(t, got)
}
================================================
FILE: main.go
================================================
package main
import (
"os"
"runtime/debug"
"github.com/dhth/prs/cmd"
)
var version = "dev"
func main() {
v := version
if version == "dev" {
info, ok := debug.ReadBuildInfo()
if ok {
v = info.Main.Version
}
}
err := cmd.Execute(v)
if err != nil {
os.Exit(1)
}
}
================================================
FILE: ui/assets/help.md
================================================
# prs Reference Manual
(scroll line by line with j/k/arrow keys or by half a page with c-d/c-u)
## Views
prs has 6 views:
- PR List View
- PR Details View
- PR Timeline List View
- PR Timeline Item Detail View
- Repo List View (only applicable when --mode=repos)
- Help View (this one)
## Keyboard Shortcuts
### General
```text
q/esc/ctrl+c go back
Q quit from anywhere
? Open Help View
d Open PR Details View
ctrl+v Show PR details using gh
```
### PR List View
```text
Indicators for current review decision:
± implies CHANGES_REQUESTED
🟡 implies REVIEW_REQUIRED
✅ implies APPROVED
⏎/tab/shift+tab/2 Switch focus to PR Timeline View
ctrl+s Switch focus to Repo List View (when --mode=repos)
ctrl+d Show PR diff
ctrl+r Reload PR list
ctrl+b Open PR in browser
```
### PR Details View
```text
h/N/← Go to previous section
l/n/→ Go to next section
1/2/3... Go to specific section
J/] Go to next PR
K/[ Go to previous PR
d Go back to last view
ctrl+b Open PR in browser
```
### Timeline List View
```text
tab/shift+tab/1 Switch focus to PR List View
⏎/3 Show details for PR timeline item (when applicable)
ctrl+d Show PR diff
ctrl+b Open timeline item in browser
ctrl+r Reload PR timeline
```
### Timeline Item Detail View
```text
1 Switch focus to PR List View
2 Switch focus to PR Timeline List View
ctrl+d Show PR diff
ctrl+b Open timeline item in browser
h/N/← Go to previous section
l/n/→ Go to next section
```
================================================
FILE: ui/cmds.go
================================================
package ui
import (
"errors"
"fmt"
"os/exec"
"runtime"
"time"
tea "charm.land/bubbletea/v2"
ghapi "github.com/cli/go-gh/v2/pkg/api"
)
var errOSNotSupported = errors.New("OS not supported")
func chooseRepo(repo string) tea.Cmd {
return func() tea.Msg {
return repoChosenMsg{repo}
}
}
func openURLInBrowser(url string) tea.Cmd {
return func() tea.Msg {
var openCmd string
var args []string
switch runtime.GOOS {
case "darwin":
openCmd = "open"
args = []string{url}
case "linux":
openCmd = "xdg-open"
args = []string{url}
case "windows":
openCmd = "rundll32"
args = []string{"url.dll,FileProtocolHandler", url}
default:
return urlOpenedinBrowserMsg{url: url, err: errOSNotSupported}
}
c := exec.Command(openCmd, args...)
err := c.Run()
return urlOpenedinBrowserMsg{url: url, err: err}
}
}
func showDiff(repoOwner, repoName string, prNumber int) tea.Cmd {
cmd := []string{
"gh",
"--repo",
fmt.Sprintf("%s/%s", repoOwner, repoName),
"pr",
"diff",
fmt.Sprintf("%d", prNumber),
}
c := exec.Command(cmd[0], cmd[1:]...)
return tea.ExecProcess(c, func(err error) tea.Msg {
if err != nil {
return prDiffDoneMsg{err: err}
}
return tea.Msg(prDiffDoneMsg{})
})
}
func showPR(repoOwner, repoName string, prNumber int) tea.Cmd {
cmd := []string{
"gh",
"--repo",
fmt.Sprintf("%s/%s", repoOwner, repoName),
"pr",
"view",
"--comments",
fmt.Sprintf("%d", prNumber),
}
c := exec.Command(cmd[0], cmd[1:]...)
return tea.ExecProcess(c, func(err error) tea.Msg {
if err != nil {
return prDiffDoneMsg{err: err}
}
return tea.Msg(prDiffDoneMsg{})
})
}
func hideHelp(interval time.Duration) tea.Cmd {
return tea.Tick(interval, func(time.Time) tea.Msg {
return hideHelpMsg{}
})
}
func fetchPRSFromQuery(ghClient *ghapi.GraphQLClient, queryStr string, prCount int) tea.Cmd {
return func() tea.Msg {
prs, err := getPRDataFromQuery(ghClient, queryStr, prCount)
return prsFetchedMsg{prs, err}
}
}
func fetchPRSForRepo(ghClient *ghapi.GraphQLClient, repoOwner string, repoName string, prCount int) tea.Cmd {
return func() tea.Msg {
queryStr := fmt.Sprintf("type:pr repo:%s/%s sort:updated-desc", repoOwner, repoName)
prs, err := getPRDataFromQuery(ghClient, queryStr, prCount)
return prsFetchedMsg{prs, err}
}
}
func fetchPRMetadata(ghClient *ghapi.GraphQLClient, repoOwner, repoName string, prNumber int) tea.Cmd {
return func() tea.Msg {
metadata, err := getPRMetadata(ghClient, repoOwner, repoName, prNumber)
return prMetadataFetchedMsg{repoOwner, repoName, prNumber, metadata, err}
}
}
func fetchPRTLItems(ghClient *ghapi.GraphQLClient, repoOwner string, repoName string, prNumber int, tlItemsCount int, setItems bool) tea.Cmd {
return func() tea.Msg {
prTLItems, err := getPRTLData(ghClient, repoOwner, repoName, prNumber, tlItemsCount)
return prTLFetchedMsg{repoOwner, repoName, prNumber, prTLItems, setItems, err}
}
}
================================================
FILE: ui/colors.go
================================================
package ui
var colors = []string{
"#fe77a4",
"#d3869a",
"#ff4c8b",
"#ffb0c2",
"#df748b",
"#ff6682",
"#f19597",
"#d89e9d",
"#fc5260",
"#e96462",
"#ffb5a2",
"#febcac",
"#f0947b",
"#ff6334",
"#af9084",
"#ff5405",
"#e98658",
"#be876e",
"#ff803b",
"#fd780b",
"#ff9743",
"#e2ac85",
"#d67717",
"#d4925c",
"#ffb472",
"#fe9103",
"#de9644",
"#dc8b00",
"#ffb13c",
"#c9b094",
"#faca7d",
"#c7921f",
"#c6a267",
"#d3cdc5",
"#fabd2f",
"#dcad50",
"#daa402",
"#ffc20c",
"#fbcf56",
"#b29807",
"#e7c727",
"#c7b648",
"#9c9360",
"#cec48b",
"#bbb206",
"#ddd601",
"#d1cc74",
"#b8bb26",
"#acaa5e",
"#b4c800",
"#a6b92b",
"#a8b64c",
"#aab08a",
"#849843",
"#a8d906",
"#a8a9a3",
"#88b500",
"#add562",
"#a0d845",
"#8de107",
"#829b60",
"#7db839",
"#94bc63",
"#71c200",
"#b5d092",
"#6e9f3a",
"#51a100",
"#b5e48c",
"#8ce852",
"#59d412",
"#89d967",
"#59c435",
"#4ba539",
"#00b700",
"#00db04",
"#9ae089",
"#6fbd63",
"#83b87a",
"#5ddb63",
"#04eb4d",
"#7a9879",
"#00ce48",
"#05b64c",
"#9cdea5",
"#64d97f",
"#8fbc96",
"#4daa67",
"#00d977",
"#12b667",
"#6ed999",
"#63bd8f",
"#00d990",
"#a7e0c2",
"#0abe88",
"#90b4a6",
"#83a598",
"#5cab95",
"#b9d9cf",
"#03d7b3",
"#00b499",
"#6fd0bd",
"#1edacd",
"#19b7b2",
"#89cbce",
"#4dcfdb",
"#62a6ae",
"#90e1ef",
"#01aac0",
"#48cae4",
"#00ddff",
"#6ac1db",
"#00c3f9",
"#99bbcd",
"#149ccd",
"#6da2c6",
"#7bcaff",
"#07b1fa",
"#b4d4fb",
"#629fdb",
"#5aaaff",
"#0798ff",
"#4896ef",
"#bbd1ff",
"#9fb9f0",
"#949aab",
"#7b8ad5",
"#8498fb",
"#aaaffc",
"#8187dc",
"#ada7ff",
"#aba3ca",
"#d2c8f2",
"#a681fb",
"#b798f0",
"#c3a4e1",
"#ce8cf7",
"#c97df9",
"#e6a5f4",
"#e47cfb",
"#ffc6ff",
"#f344ff",
"#a882a7",
"#c57fbf",
"#ff4ded",
"#f081de",
"#fc69e6",
"#dfa5ca",
"#f646c1",
"#ceb4c3",
"#f27abe",
"#ae8c99",
"#ee91b6",
}
================================================
FILE: ui/gh.go
================================================
package ui
import (
"log"
ghapi "github.com/cli/go-gh/v2/pkg/api"
ghgql "github.com/cli/shurcooL-graphql"
)
func getPRDataFromQuery(ghClient *ghapi.GraphQLClient, queryStr string, prCount int) ([]pr, error) {
var query prSearchQuery
variables := map[string]any{
"query": ghgql.String(queryStr),
"count": ghgql.Int(prCount),
}
err := ghClient.Query("PRQuery", &query, variables)
if err != nil {
return nil, err
}
var prs []pr //nolint:prealloc
for _, edge := range query.Search.Edges {
if edge.Node.Type != "PullRequest" {
continue
}
prs = append(prs, edge.Node.pr)
}
return prs, nil
}
func getPRMetadata(ghClient *ghapi.GraphQLClient, repoOwner string, repoName string, prNumber int) (prDetails, error) {
var query prDetailsQuery
variables := map[string]any{
"repositoryOwner": ghgql.String(repoOwner),
"repositoryName": ghgql.String(repoName),
"pullRequestNumber": ghgql.Int(prNumber),
"reviewRequestsCount": ghgql.Int(reviewRequestsCount),
"latestReviewsCount": ghgql.Int(latestReviewsCount),
"filesCount": ghgql.Int(filesCount),
"labelsCount": ghgql.Int(labelsCount),
"assigneesCount": ghgql.Int(assigneesCount),
"issuesCount": ghgql.Int(issuesCount),
"participantsCount": ghgql.Int(participantsCount),
"commentsCount": ghgql.Int(commentsCount),
"commitsCount": ghgql.Int(commitsCount),
"statusCheckContextsCount": ghgql.Int(statusCheckContextsCount),
}
err := ghClient.Query("PRTL", &query, variables)
if err != nil {
log.Printf("error: %s\n", err)
return prDetails{}, err
}
return query.RepositoryOwner.Repository.PullRequest, nil
}
func getPRTLData(ghClient *ghapi.GraphQLClient, repoOwner string, repoName string, prNumber int, tlItemsCount int) ([]prTLItem, error) {
var query prTLQuery
variables := map[string]any{
"repositoryOwner": ghgql.String(repoOwner),
"repositoryName": ghgql.String(repoName),
"pullRequestNumber": ghgql.Int(prNumber),
"timelineItemsCount": ghgql.Int(tlItemsCount),
}
err := ghClient.Query("PRTL", &query, variables)
if err != nil {
return nil, err
}
return query.RepositoryOwner.Repository.PullRequest.TimelineItems.Nodes, nil
}
================================================
FILE: ui/initial.go
================================================
package ui
import (
"charm.land/bubbles/v2/list"
"charm.land/lipgloss/v2"
ghapi "github.com/cli/go-gh/v2/pkg/api"
)
const (
fetchingPRsTitle = "fetching PRs..."
)
func InitialModel(ghClient *ghapi.GraphQLClient, config Config, mode Mode) Model {
prListDel := newPRListItemDel()
prTLListDel := newPRTLListItemDel()
prDetailsCache := make(map[string]prDetails)
prTLCache := make(map[string][]*prTLItemResult)
prDetailsCurSectionCache := make(map[string]uint)
m := Model{
mode: mode,
config: config,
ghClient: ghClient,
prsList: list.New(nil, prListDel, 0, 0),
prTLList: list.New(nil, prTLListDel, 0, 0),
prDetailsCache: prDetailsCache,
prTLCache: prTLCache,
showHelp: true,
terminalDetails: terminalDetails{width: widthBudgetDefault},
prDetailsCurSectionCache: prDetailsCurSectionCache,
}
switch m.mode {
case RepoMode:
repoListItems := make([]list.Item, len(config.Repos))
if mode == RepoMode {
for i, issue := range config.Repos {
repoListItems[i] = issue
}
}
repoListDel := newRepoListItemDel()
m.repoList = list.New(repoListItems, repoListDel, 0, 0)
m.repoList.Title = "Repos"
m.repoList.SetStatusBarItemName("repo", "repos")
m.repoList.DisableQuitKeybindings()
m.repoList.SetShowHelp(false)
m.repoList.SetFilteringEnabled(false)
m.repoList.Styles.Title = m.repoList.Styles.Title.Background(lipgloss.Color(repoListColor)).
Foreground(lipgloss.Color(defaultBackgroundColor)).
Bold(true)
m.repoList.KeyMap.PrevPage.SetKeys("left", "h", "pgup")
m.repoList.KeyMap.NextPage.SetKeys("right", "l", "pgdown")
case QueryMode:
m.activePane = prListView
}
m.prsList.Title = fetchingPRsTitle
m.prsList.SetStatusBarItemName("PR", "PRs")
m.prsList.DisableQuitKeybindings()
m.prsList.SetShowHelp(false)
m.prsList.SetFilteringEnabled(false)
m.prsList.Styles.Title = m.prsList.Styles.Title.Background(lipgloss.Color(fetchingColor)).
Foreground(lipgloss.Color(defaultBackgroundColor)).
Bold(true)
m.prsList.KeyMap.PrevPage.SetKeys("left", "h", "pgup")
m.prsList.KeyMap.NextPage.SetKeys("right", "l", "pgdown")
m.prTLList.Title = "fetching timeline..."
m.prTLList.SetStatusBarItemName("item", "items")
m.prTLList.DisableQuitKeybindings()
m.prTLList.SetShowHelp(false)
m.prTLList.SetFilteringEnabled(false)
m.prTLList.Styles.Title = m.prTLList.Styles.Title.Background(lipgloss.Color(prTLListColor)).
Foreground(lipgloss.Color(defaultBackgroundColor)).
Bold(true)
m.prTLList.KeyMap.PrevPage.SetKeys("left", "h", "pgup")
m.prTLList.KeyMap.NextPage.SetKeys("right", "l", "pgdown")
return m
}
================================================
FILE: ui/model.go
================================================
package ui
import (
"time"
"charm.land/bubbles/v2/list"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/glamour"
ghapi "github.com/cli/go-gh/v2/pkg/api"
)
type Pane uint
const (
repoListView Pane = iota
prListView
prDetailsView
reviewPRListView
prTLListView
prTLItemDetailView
helpView
)
type Mode uint
const (
QueryMode Mode = iota
RepoMode
)
type Model struct {
mode Mode
config Config
ghClient *ghapi.GraphQLClient
repoOwner string
repoName string
repoList list.Model
prsList list.Model
prTLList list.Model
prCache []*prResult
prTLItemDetailVP viewport.Model
prTLItemDetailVPReady bool
prDetailsTitle string
prTLItemDetailTitle string
prDetailsVP viewport.Model
prDetailsVPReady bool
prDetailsCache map[string]prDetails
prTLCache map[string][]*prTLItemResult
message string
helpVP viewport.Model
helpVPReady bool
activePane Pane
lastPane Pane
secondLastActivePane Pane
showHelp bool
repoChosen bool
terminalDetails terminalDetails
mdRenderer *glamour.TermRenderer
prDetailsCurrentSection uint
prDetailsCurSectionCache map[string]uint
prRevCurCmtNum uint
}
func (m Model) Init() tea.Cmd {
var cmds []tea.Cmd
cmds = append(cmds, hideHelp(time.Minute*1))
if m.mode == QueryMode {
cmds = append(cmds, fetchPRSFromQuery(m.ghClient, *m.config.Query, m.config.PRCount))
}
return tea.Batch(cmds...)
}
================================================
FILE: ui/msgs.go
================================================
package ui
type hideHelpMsg struct{}
type repoChosenMsg struct {
repo string
}
type prsFetchedMsg struct {
prs []pr
err error
}
type prMetadataFetchedMsg struct {
repoOwner string
repoName string
prNumber int
metadata prDetails
err error
}
type reviewPRsFetchedMsg prsFetchedMsg
type authoredPRsFetchedMsg prsFetchedMsg
type prTLFetchedMsg struct {
repoOwner string
repoName string
prNumber int
prTLItems []prTLItem
setItems bool
err error
}
type urlOpenedinBrowserMsg struct {
url string
err error
}
type prDiffDoneMsg struct {
err error
}
type prViewDoneMsg struct {
err error
}
================================================
FILE: ui/navigation.go
================================================
package ui
import (
"fmt"
"strings"
)
const (
maxCommentsForNavIndicator = 8
disabledSectionMarker = "◌"
inactiveSectionMarker = "◯"
activeSectionMarker = "●"
)
func (m *Model) setPRDetailsContent(prDetails prDetails, section PRDetailSection) {
content := fmt.Sprintf(`# %s (%s/%s/pull/%d)
`, prDetails.PRTitle, prDetails.Repository.Owner.Login, prDetails.Repository.Name, prDetails.Number,
)
switch section {
case PRMetadata:
content += prDetails.Metadata()
case PRDescription:
content += prDetails.Description()
case PRChecks:
content += prDetails.Checks()
case PRReferences:
content += prDetails.References()
case PRFilesChanged:
content += prDetails.FilesChanged()
case PRCommits:
content += prDetails.CommitsList()
case PRComments:
content += prDetails.CommentsList()
}
glErr := true
if m.mdRenderer != nil {
contentGl, err := m.mdRenderer.Render(content)
if err == nil {
m.prDetailsVP.SetContent(contentGl)
glErr = false
}
}
if glErr {
m.prDetailsVP.SetContent(content)
}
sections := make([]string, len(PRDetailsSectionList))
for i := range PRDetailsSectionList {
sections[i] = inactiveSectionMarker
}
if prDetails.Body == "" {
sections[PRDescription] = disabledSectionMarker
}
// not foolproof, but should work in most cases
// func (pr prDetails) Checks() will return with an appropriate message in
// that case
//nolint:staticcheck
if !(len(prDetails.LastCommit.Nodes) > 0 && prDetails.LastCommit.Nodes[0].Commit.StatusCheckRollup != nil) {
sections[PRChecks] = disabledSectionMarker
}
if len(prDetails.IssueReferences.Nodes) == 0 {
sections[PRReferences] = disabledSectionMarker
}
if len(prDetails.Files.Nodes) == 0 {
sections[PRFilesChanged] = disabledSectionMarker
}
if len(prDetails.Commits.Nodes) == 0 {
sections[PRCommits] = disabledSectionMarker
}
if len(prDetails.Comments.Nodes) == 0 {
sections[PRComments] = disabledSectionMarker
}
sections[section] = activeSectionMarker
m.prDetailsTitle = fmt.Sprintf("PR Details%s", " "+strings.Join(sections, " "))
m.prDetailsVP.GotoTop()
}
func (m *Model) GoToPRDetailSection(section uint) {
if m.prDetailsCurrentSection == section {
return
}
pr, ok := m.prsList.SelectedItem().(*prResult)
if !ok {
return
}
prDetails, ok := m.prDetailsCache[fmt.Sprintf("%s/%s:%d", pr.pr.Repository.Owner.Login, pr.pr.Repository.Name, pr.pr.Number)]
if !ok {
return
}
switch section {
case 1:
if prDetails.Body == "" {
return
}
case 2:
//nolint:staticcheck
if !(len(prDetails.LastCommit.Nodes) > 0 && prDetails.LastCommit.Nodes[0].Commit.StatusCheckRollup != nil) {
return
}
case 3:
if len(prDetails.IssueReferences.Nodes) == 0 {
return
}
case 4:
if len(prDetails.Files.Nodes) == 0 {
return
}
case 5:
if len(prDetails.Commits.Nodes) == 0 {
return
}
case 6:
if len(prDetails.Comments.Nodes) == 0 {
return
}
}
m.setPRDetailsContent(prDetails, PRDetailsSectionList[section])
m.prDetailsCurrentSection = section
}
func (m *Model) setPRReviewCmt(tlItem *prTLItem, commentNum uint) {
revCmts := tlItem.PullRequestReview.Comments.Nodes
var sectionsStr string
if len(revCmts) > maxCommentsForNavIndicator {
sectionsStr = fmt.Sprintf("%d/%d", commentNum+1, len(revCmts))
} else if len(revCmts) > 1 {
sections := make([]string, len(revCmts))
for i := range revCmts {
sections[i] = inactiveSectionMarker
}
sections[commentNum] = activeSectionMarker
sectionsStr = " " + strings.Join(sections, " ")
}
var outdated string
if revCmts[commentNum].Outdated {
outdated = " `(outdated)`"
}
content := fmt.Sprintf("# from @%s\n## %s%s\n%s\n```diff\n%s\n```", tlItem.PullRequestReview.Author.Login, revCmts[commentNum].Path, outdated, revCmts[commentNum].Body, revCmts[commentNum].DiffHunk)
glErr := true
if m.mdRenderer != nil {
contentGl, err := m.mdRenderer.Render(content)
if err == nil {
m.prTLItemDetailVP.SetContent(contentGl)
glErr = false
}
}
if glErr {
m.prDetailsVP.SetContent(content)
}
m.prTLItemDetailTitle = fmt.Sprintf("Review Comments%s", sectionsStr)
m.prTLItemDetailVP.GotoTop()
}
================================================
FILE: ui/pr_delegate.go
================================================
package ui
import (
"charm.land/bubbles/v2/list"
"charm.land/lipgloss/v2"
)
func newPRListItemDel() list.DefaultDelegate {
d := list.NewDefaultDelegate()
d.Styles.SelectedTitle = d.Styles.
SelectedTitle.
Foreground(lipgloss.Color(prListColor)).
BorderLeftForeground(lipgloss.Color(prListColor))
d.Styles.SelectedDesc = d.Styles.
SelectedTitle
return d
}
================================================
FILE: ui/prtl_delegate.go
================================================
package ui
import (
"charm.land/bubbles/v2/list"
"charm.land/lipgloss/v2"
)
func newPRTLListItemDel() list.DefaultDelegate {
d := list.NewDefaultDelegate()
d.Styles.SelectedTitle = d.Styles.
SelectedTitle.
Foreground(lipgloss.Color(prTLListColor)).
BorderLeftForeground(lipgloss.Color(prTLListColor))
d.Styles.SelectedDesc = d.Styles.
SelectedTitle
return d
}
================================================
FILE: ui/render_helpers.go
================================================
package ui
import (
"fmt"
"strings"
humanize "github.com/dustin/go-humanize"
)
const (
widthBudgetDefault = 80
responsiveWidthCutOff = 100
wideScreenWidthFrac = 0.9
)
func getPRTitle(pr *pr) string {
if pr == nil {
return ""
}
var reviewDecision string
if pr.ReviewDecision != nil {
switch *pr.ReviewDecision {
case "CHANGES_REQUESTED":
reviewDecision = "± "
case "APPROVED":
reviewDecision = "✅ "
case "REVIEW_REQUIRED":
reviewDecision = "🟡 "
}
}
return fmt.Sprintf("%s#%2d %s", reviewDecision, pr.Number, pr.PRTitle)
}
func getPRDesc(pr *pr, mode Mode, terminalDetails terminalDetails) string {
if pr == nil {
return ""
}
var widthBudget int
var widthFixed int
var additions string
var deletions string
var reviews string
var desc string
switch mode {
case RepoMode:
widthFixed = 30
case QueryMode:
widthFixed = 16
}
if terminalDetails.width > responsiveWidthCutOff {
widthBudget = getFracInt(terminalDetails.width, wideScreenWidthFrac) - widthFixed
} else {
widthBudget = terminalDetails.width - widthFixed
}
if widthBudget < 0 {
widthBudget = widthBudgetDefault
}
if pr.Additions > 0 {
additions = additionsStyle.Render(fmt.Sprintf("+%d", pr.Additions))
}
if pr.Deletions > 0 {
deletions = deletionsStyle.Render(fmt.Sprintf("-%d", pr.Deletions))
}
if pr.Reviews.TotalCount > 0 {
reviews = numReviewsStyle.Render(fmt.Sprintf("%dr", pr.Reviews.TotalCount))
}
switch mode {
case RepoMode:
updatedAt := dateStyle.Render(RightPadTrim("updated "+humanize.Time(pr.UpdatedAt), getFracInt(widthBudget, 0.3)))
author := getDynamicStyle(pr.Author.Login).Render(RightPadTrim(pr.Author.Login, getFracInt(widthBudget, 0.7)))
state := prStyle(pr.State).Render(pr.State)
desc = fmt.Sprintf("%s%s%s%s%s%s", author, updatedAt, state, additions, deletions, reviews)
case QueryMode:
repoStr := fmt.Sprintf("%s/%s", pr.Repository.Owner.Login, pr.Repository.Name)
updatedAt := dateStyle.Render(RightPadTrim("updated "+humanize.Time(pr.UpdatedAt), getFracInt(widthBudget, 0.3)))
author := getDynamicStyle(pr.Author.Login).Render(RightPadTrim(pr.Author.Login, getFracInt(widthBudget, 0.4)))
state := prStyle(pr.State).Render(pr.State)
repo := getDynamicStyle(repoStr).Render(RightPadTrim(repoStr, getFracInt(widthBudget, 0.3)))
desc = fmt.Sprintf("%s%s%s%s%s%s%s", author, repo, updatedAt, state, additions, deletions, reviews)
}
return desc
}
func getPRTLItemTitle(item *prTLItem) string {
var title string
var date string
switch item.Type {
case tlItemPRCommit:
if item.PullRequestCommit.Commit.Author.User != nil {
author := getDynamicStyle(item.PullRequestCommit.Commit.Author.User.Login).Render(item.PullRequestCommit.Commit.Author.User.Login)
date = dateStyle.Render(humanize.Time(item.PullRequestCommit.Commit.CommittedDate))
title = fmt.Sprintf("%spushed a commit%s", author, date)
} else {
title = fmt.Sprintf("%s pushed a commit", item.PullRequestCommit.Commit.Author.Name)
}
case tlItemHeadRefForcePushed:
actor := getDynamicStyle(item.HeadRefForcePushed.Actor.Login).Render(item.HeadRefForcePushed.Actor.Login)
beforeCommitHash := item.HeadRefForcePushed.BeforeCommit.AbbreviatedOid
afterCommitHash := item.HeadRefForcePushed.AfterCommit.AbbreviatedOid
date = dateStyle.Render(humanize.Time(item.HeadRefForcePushed.CreatedAt))
title = fmt.Sprintf("%sforce pushed head ref from %s to %s%s", actor, beforeCommitHash, afterCommitHash, date)
case tlItemPRReadyForReview:
actor := getDynamicStyle(item.PullRequestReadyForReview.Actor.Login).Render(item.PullRequestReadyForReview.Actor.Login)
title = fmt.Sprintf("%smarked PR as ready for review", actor)
case tlItemPRReviewRequested:
actor := getDynamicStyle(item.PullRequestReviewRequested.Actor.Login).Render(item.PullRequestReviewRequested.Actor.Login)
reviewer := getDynamicStyle(item.PullRequestReviewRequested.RequestedReviewer.User.Login).Render(item.PullRequestReviewRequested.RequestedReviewer.User.Login)
title = fmt.Sprintf("%srequested a review from %s", actor, reviewer)
case tlItemPRReview:
author := getDynamicStyle(item.PullRequestReview.Author.Login).Render(item.PullRequestReview.Author.Login)
date = dateStyle.Render(humanize.Time(item.PullRequestReview.CreatedAt))
var comments string
var more string
if item.PullRequestReview.Comments.TotalCount > 0 {
more = " ⏎"
}
if item.PullRequestReview.Comments.TotalCount > 1 {
comments = numCommentsStyle.Render(fmt.Sprintf("with %d comments", item.PullRequestReview.Comments.TotalCount))
} else if item.PullRequestReview.Comments.TotalCount == 1 {
comments = numCommentsStyle.Render("with 1 comment")
}
title = fmt.Sprintf("%sreviewed%s%s%s", author, comments, date, more)
case tlItemMergedEvent:
author := getDynamicStyle(item.MergedEvent.Actor.Login).Render(item.MergedEvent.Actor.Login)
date = dateStyle.Render(humanize.Time(item.MergedEvent.CreatedAt))
title = fmt.Sprintf("%smerged the PR%s", author, date)
}
return title
}
func getPRTLItemDesc(item *prTLItem) string {
var desc string
switch item.Type {
case tlItemPRCommit:
desc = fmt.Sprintf("📧 %s", item.PullRequestCommit.Commit.MessageHeadline)
case tlItemHeadRefForcePushed:
desc = fmt.Sprintf("💪 %s", item.HeadRefForcePushed.AfterCommit.MessageHeadline)
case tlItemPRReadyForReview:
desc = fmt.Sprintf("🚦%s", dateStyle.Render(humanize.Time(item.PullRequestReadyForReview.CreatedAt)))
case tlItemPRReviewRequested:
desc = fmt.Sprintf("🙏%s", dateStyle.Render(humanize.Time(item.PullRequestReviewRequested.CreatedAt)))
case tlItemPRReview:
reviewState := reviewStyle(item.PullRequestReview.State).Render(item.PullRequestReview.State)
var comment string
if item.PullRequestReview.Body != "" {
comment = fmt.Sprintf("with comment: %s", strings.Split(item.PullRequestReview.Body, "\r")[0])
}
desc = fmt.Sprintf("🔎 %s%s", reviewState, comment)
case tlItemMergedEvent:
desc = fmt.Sprintf("🚀 message: %s", item.MergedEvent.MergeCommit.MessageHeadline)
}
return desc
}
================================================
FILE: ui/repo_delegate.go
================================================
package ui
import (
"charm.land/bubbles/v2/list"
"charm.land/lipgloss/v2"
)
func newRepoListItemDel() list.DefaultDelegate {
d := list.NewDefaultDelegate()
d.Styles.SelectedTitle = d.Styles.
SelectedTitle.
Foreground(lipgloss.Color(repoListColor)).
BorderLeftForeground(lipgloss.Color(repoListColor))
d.Styles.SelectedDesc = d.Styles.
SelectedTitle
return d
}
================================================
FILE: ui/styles.go
================================================
package ui
import (
"hash/fnv"
"charm.land/lipgloss/v2"
)
const (
defaultBackgroundColor = "#282828"
repoListColor = "#b8bb26"
prListColor = "#fe8019"
prTLListColor = "#d3869b"
prDetailsTitleColor = "#fabd2f"
revCmtListColor = "#8ec07c"
prOpenColor = "#fabd2f"
prMergedColor = "#b8bb26"
prClosedColor = "#928374"
additionsColor = "#8ec07c"
deletionsColor = "#fb4934"
reviewCommentedColor = "#83a598"
reviewApprovedColor = "#b8bb26"
reviewChangesRequestedColor = "#fabd2f"
reviewDismissedColor = "#928374"
dateColor = "#928374"
repoColor = "#bdae93"
numReviewsColor = "#665c54"
numCommentsColor = "#83a598"
footerColor = "#7c6f64"
helpMsgColor = "#83a598"
helpViewTitleColor = "#83a598"
toolNameColor = "#b8bb26"
fetchingColor = "#928374"
)
func getDynamicStyle(author string) lipgloss.Style {
h := fnv.New32()
h.Write([]byte(author))
hash := h.Sum32()
color := colors[int(hash)%len(colors)]
st := lipgloss.NewStyle().
PaddingRight(1).
Foreground(lipgloss.Color(color))
return st
}
var (
baseStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color(defaultBackgroundColor))
toolNameStyle = baseStyle.
Align(lipgloss.Center).
PaddingLeft(1).
PaddingRight(1).
Bold(true).
Background(lipgloss.Color(toolNameColor))
listStyle = baseStyle.
PaddingTop(1).
PaddingBottom(1).
Foreground(lipgloss.Color(defaultBackgroundColor))
viewPortStyle = lipgloss.NewStyle().
PaddingTop(1).
PaddingBottom(1)
helpMsgStyle = baseStyle.
PaddingLeft(2).
Bold(true).
Foreground(lipgloss.Color(helpMsgColor))
dateStyle = lipgloss.NewStyle().
PaddingLeft(1).
Foreground(lipgloss.Color(dateColor))
numReviewsStyle = lipgloss.NewStyle().
PaddingLeft(1).
Foreground(lipgloss.Color(numReviewsColor))
numCommentsStyle = lipgloss.NewStyle().
PaddingLeft(1).
Foreground(lipgloss.Color(numCommentsColor))
linesChangedStyle = lipgloss.NewStyle().
PaddingLeft(1)
additionsStyle = linesChangedStyle.
PaddingLeft(2).
Foreground(lipgloss.Color(additionsColor))
deletionsStyle = linesChangedStyle.
Foreground(lipgloss.Color(deletionsColor))
prStyle = func(state string) lipgloss.Style {
st := lipgloss.NewStyle().
PaddingLeft(1).
PaddingRight(1).
Width(8).
Bold(true).
Align(lipgloss.Center).
Foreground(lipgloss.Color(defaultBackgroundColor))
var bgColor string
switch state {
case prStateOpen:
bgColor = prOpenColor
case prStateMerged:
bgColor = prMergedColor
default:
bgColor = prClosedColor
}
return st.Background(lipgloss.Color(bgColor))
}
reviewStyle = func(state string) lipgloss.Style {
st := lipgloss.NewStyle().
PaddingRight(1).
Bold(true).
Align(lipgloss.Center)
var bgColor string
switch state {
case reviewCommented:
bgColor = reviewCommentedColor
case reviewApproved:
bgColor = reviewApprovedColor
case reviewChangesRequested:
bgColor = reviewChangesRequestedColor
default:
bgColor = reviewDismissedColor
}
return st.Foreground(lipgloss.Color(bgColor))
}
footerStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color(defaultBackgroundColor)).
Background(lipgloss.Color(footerColor))
titleStyle = lipgloss.NewStyle().
PaddingLeft(1).
PaddingRight(1).
Bold(true).
Foreground(lipgloss.Color(defaultBackgroundColor))
helpVPTitleStyle = titleStyle.
Background(lipgloss.Color(helpViewTitleColor))
prDetailsTitleStyle = titleStyle.
Background(lipgloss.Color(prDetailsTitleColor))
)
================================================
FILE: ui/types.go
================================================
package ui
import (
"fmt"
"strings"
"time"
"github.com/dustin/go-humanize"
)
const (
prStateOpen = "OPEN"
prStateMerged = "MERGED"
prStateClosed = "CLOSED"
prRevDecChangesReq = "CHANGES_REQUESTED"
prRevDecApproved = "APPROVED"
prRevDecRevReq = "REVIEW_REQUIRED"
tlItemPRCommit = "PullRequestCommit"
tlItemPRReadyForReview = "ReadyForReviewEvent"
tlItemPRReviewRequested = "ReviewRequestedEvent"
tlItemPRReview = "PullRequestReview"
tlItemMergedEvent = "MergedEvent"
tlItemHeadRefForcePushed = "HeadRefForcePushedEvent"
reviewPending = "PENDING"
reviewCommented = "COMMENTED"
reviewApproved = "APPROVED"
reviewChangesRequested = "CHANGES_REQUESTED"
reviewDismissed = "DISMISSED"
checkStatusStateCompleted = "COMPLETED"
checkRunType = "CheckRun"
statusContextType = "StatusContext"
checkConclusionStateSuccess = "SUCCESS"
checkConclusionStateFailure = "FAILURE"
checkConclusionStateError = "ERROR"
statusStateSuccess = "SUCCESS"
statusStateFailure = "FAILURE"
statusStateError = "ERROR"
requestedReviewerUser = "User"
prDetailsMetadataKeyPadding = 30
checkNamePadding = 40
statusConclusionPadding = 16
reviewRequestsCount = 20
latestReviewsCount = 30
filesCount = 50
labelsCount = 10
assigneesCount = 10
issuesCount = 10
participantsCount = 30
commentsCount = 10
commitsCount = 30
statusCheckContextsCount = 50
timeFormat = "2006/01/02 15:04"
mergeableConflicting = "CONFLICTING"
noChecksHeader = "## No Checks"
)
type terminalDetails struct {
width int
}
type SourceConfig struct {
DiffPager *string `yaml:"diff-pager"`
PRCount *int `yaml:"pr-count"`
Sources *[]struct {
Owner string `yaml:"owner"`
Repos []struct {
Name string `yaml:"name"`
} `yaml:"repos"`
} `yaml:"sources"`
Query *string `yaml:"query"`
}
type Repo struct {
Owner string
Name string
}
type Config struct {
PRCount int
Repos []Repo
Query *string
}
type prResult struct {
pr *pr
title string
description string
identifier string
}
type prTLItemResult struct {
item *prTLItem
title string
description string
}
type pr struct {
Number int
PRTitle string `graphql:"prTitle: title"`
Repository struct {
Owner struct {
Login string
}
Name string
}
State string
Mergeable string
IsDraft bool
ReviewDecision *string
CreatedAt time.Time
UpdatedAt time.Time
ClosedAt *time.Time
MergedAt *time.Time
LastEditedAt *time.Time
Author struct {
Login string
}
URL string
Additions int
Deletions int
Reviews struct {
TotalCount int
}
}
type prDetails struct {
Number int
PRTitle string `graphql:"prTitle: title"`
Repository struct {
Owner struct {
Login string
}
Name string
}
State string
Mergeable string
IsDraft bool
ReviewDecision *string
CreatedAt time.Time
UpdatedAt time.Time
ClosedAt *time.Time
MergedAt *time.Time
LastEditedAt *time.Time
Author struct {
Login string
}
Additions int
Deletions int
ReviewRequests *struct {
Nodes []struct {
RequestedReviewer *struct {
Type string `graphql:"type: __typename"`
User struct {
Login string
} `graphql:"... on User "`
}
}
} `graphql:"reviewRequests (first:$reviewRequestsCount)"`
LatestReviews struct {
Nodes []struct {
Author struct {
Login string
}
State string
}
} `graphql:"latestReviews (last: $latestReviewsCount)"`
Body string
Files struct {
Nodes []struct {
Path string
Additions int
Deletions int
}
} `graphql:"files (first: $filesCount)"`
Labels struct {
Nodes []struct {
Name string
}
} `graphql:"labels (first: $labelsCount)"`
Assignees struct {
Nodes []struct {
Login string
}
} `graphql:"assignees (first: $assigneesCount)"`
IssueReferences struct {
Nodes []struct {
Number int
Title string
URL string
}
} `graphql:"closingIssuesReferences (first: $issuesCount)"`
Participants struct {
Nodes []struct {
Login string
}
} `graphql:"participants (first: $participantsCount)"`
Comments struct {
TotalCount int
Nodes []struct {
Body string
UpdatedAt time.Time
Author struct {
Login string
}
}
} `graphql:"comments (first: $commentsCount)"`
Commits struct {
TotalCount int
Nodes []struct {
Commit struct {
AbbreviatedOid string
MessageHeadline string
AuthoredDate time.Time
Author struct {
Name string
}
}
}
} `graphql:"commits (last: $commitsCount)"`
MergedBy *struct {
Login string
}
Milestone *struct {
Title string
}
LastCommit struct {
Nodes []struct {
Commit struct {
AbbreviatedOid string
StatusCheckRollup *struct {
Contexts struct {
Nodes []struct {
Type string `graphql:"type: __typename"`
CheckRun struct {
Status string
Conclusion *string
Name string
} `graphql:"... on CheckRun"`
StatusContext struct {
State string
Context string
} `graphql:"... on StatusContext"`
}
} `graphql:"contexts (first: $statusCheckContextsCount) "`
State string
}
}
}
} `graphql:"lastCommit: commits(last: 1)"`
}
type PRDetailSection uint
const (
PRMetadata PRDetailSection = iota
PRDescription
PRChecks
PRReferences
PRFilesChanged
PRCommits
PRComments
)
var PRDetailsSectionList = []PRDetailSection{
PRMetadata,
PRDescription,
PRChecks,
PRReferences,
PRFilesChanged,
PRCommits,
PRComments,
}
type prReviewComment struct {
CreatedAt time.Time
Body string
Outdated bool
DiffHunk string
Path string
URL string
}
type prSearchQuery struct {
Search struct {
Edges []struct {
Node struct {
Type string `graphql:"type: __typename"`
pr `graphql:"... on PullRequest"`
}
}
} `graphql:"search(query: $query, type: ISSUE, first: $count)"`
}
type prDetailsQuery struct {
RepositoryOwner struct {
Repository struct {
PullRequest prDetails `graphql:"pullRequest(number: $pullRequestNumber)"`
} `graphql:"repository(name: $repositoryName)"`
} `graphql:"repositoryOwner(login: $repositoryOwner)"`
}
type prTLItem struct {
Type string `graphql:"type: __typename"`
PullRequestCommit struct {
URL string
Commit struct {
CommittedDate time.Time
MessageHeadline string
Author struct {
Name string
User *struct {
Login string
}
}
}
} `graphql:"... on PullRequestCommit"`
HeadRefForcePushed struct {
CreatedAt time.Time
Actor struct {
Login string
}
BeforeCommit struct {
AbbreviatedOid string
}
AfterCommit struct {
AbbreviatedOid string
URL string
MessageHeadline string
}
} `graphql:"... on HeadRefForcePushedEvent"`
PullRequestReadyForReview struct {
CreatedAt time.Time
Actor struct {
Login string
}
} `graphql:"... on ReadyForReviewEvent"`
PullRequestReviewRequested struct {
CreatedAt time.Time
Actor struct {
Login string
}
RequestedReviewer struct {
User struct {
Login string
} `graphql:"... on User"`
}
} `graphql:"... on ReviewRequestedEvent"`
PullRequestReview struct {
URL string
CreatedAt time.Time
State string
Body string
Comments struct {
TotalCount int
Nodes []prReviewComment
} `graphql:"comments(first: 100)"`
Author struct {
Login string
}
} `graphql:"... on PullRequestReview"`
MergedEvent struct {
CreatedAt time.Time
URL string
MergeCommit struct {
MessageHeadline string
} `graphql:"mergeCommit: commit"`
Actor struct {
Login string
}
} `graphql:"... on MergedEvent"`
}
type prTLQuery struct {
RepositoryOwner struct {
Repository struct {
PullRequest struct {
TimelineItems struct {
Nodes []prTLItem
} `graphql:"timelineItems(last: $timelineItemsCount, itemTypes: [PULL_REQUEST_COMMIT, READY_FOR_REVIEW_EVENT, REVIEW_REQUESTED_EVENT, MERGED_EVENT, PULL_REQUEST_REVIEW, HEAD_REF_FORCE_PUSHED_EVENT])"`
} `graphql:"pullRequest(number: $pullRequestNumber)"`
} `graphql:"repository(name: $repositoryName)"`
} `graphql:"repositoryOwner(login: $repositoryOwner)"`
}
func (pr prDetails) Metadata() string {
var metadata []string
metadata = append(metadata, fmt.Sprintf("- %s *%s*",
RightPadTrim("State", prDetailsMetadataKeyPadding),
pr.State,
))
metadata = append(metadata, fmt.Sprintf("- %s `@%s`",
RightPadTrim("Author", prDetailsMetadataKeyPadding),
pr.Author.Login,
))
if len(pr.Assignees.Nodes) > 0 {
assignees := make([]string, len(pr.Assignees.Nodes))
for i, l := range pr.Assignees.Nodes {
assignees[i] = fmt.Sprintf("`@%s`", l.Login)
}
metadata = append(metadata, fmt.Sprintf("- %s %s",
RightPadTrim("Assignees", prDetailsMetadataKeyPadding),
strings.Join(assignees, ", "),
))
}
if len(pr.Participants.Nodes) > 0 {
participants := make([]string, len(pr.Participants.Nodes))
for i, l := range pr.Participants.Nodes {
participants[i] = fmt.Sprintf("`@%s`", l.Login)
}
metadata = append(metadata, fmt.Sprintf("- %s %s",
RightPadTrim("Participants", prDetailsMetadataKeyPadding),
strings.Join(participants, ", "),
))
}
if pr.ReviewRequests != nil && len(pr.ReviewRequests.Nodes) > 0 {
var requested []string
for _, r := range pr.ReviewRequests.Nodes {
if r.RequestedReviewer.Type != requestedReviewerUser {
continue
}
requested = append(requested, fmt.Sprintf("`@%s`", r.RequestedReviewer.User.Login))
}
if len(requested) > 0 {
metadata = append(metadata, fmt.Sprintf("- %s %s",
RightPadTrim("Review requested from", prDetailsMetadataKeyPadding),
strings.Join(requested, ", "),
))
}
}
metadata = append(metadata, fmt.Sprintf("- %s %s (%s)",
RightPadTrim("Created at", prDetailsMetadataKeyPadding),
pr.CreatedAt.Format(timeFormat),
humanize.Time(pr.CreatedAt),
))
if pr.LastEditedAt != nil && *pr.LastEditedAt != pr.CreatedAt {
metadata = append(metadata, fmt.Sprintf("- %s %s (%s)",
RightPadTrim("Last edited at", prDetailsMetadataKeyPadding),
pr.LastEditedAt.Format(timeFormat),
humanize.Time(*pr.LastEditedAt),
))
}
switch pr.State {
case prStateClosed:
if pr.ClosedAt != nil {
metadata = append(metadata, fmt.Sprintf("- %s %s (%s)",
RightPadTrim("Closed at", prDetailsMetadataKeyPadding),
pr.ClosedAt.Format(timeFormat),
humanize.Time(*pr.ClosedAt),
))
}
case prStateMerged:
metadata = append(metadata, fmt.Sprintf("- %s %s (%s) by `@%s`",
RightPadTrim("Merged at", prDetailsMetadataKeyPadding),
pr.MergedAt.Format(timeFormat),
humanize.Time(*pr.MergedAt),
pr.MergedBy.Login,
))
}
if len(pr.Labels.Nodes) > 0 {
labels := make([]string, len(pr.Labels.Nodes))
for i, l := range pr.Labels.Nodes {
labels[i] = fmt.Sprintf("*%s*", l.Name)
}
metadata = append(metadata, fmt.Sprintf("- %s %s",
RightPadTrim("Labels", prDetailsMetadataKeyPadding),
strings.Join(labels, " "),
))
}
if pr.Commits.TotalCount > 0 {
metadata = append(metadata, fmt.Sprintf("- %s %d",
RightPadTrim("Commits", prDetailsMetadataKeyPadding),
pr.Commits.TotalCount,
))
}
if pr.Comments.TotalCount > 0 {
metadata = append(metadata, fmt.Sprintf("- %s %d",
RightPadTrim("Comments", prDetailsMetadataKeyPadding),
pr.Comments.TotalCount,
))
}
if pr.IsDraft {
metadata = append(metadata, fmt.Sprintf("- %s `true`",
RightPadTrim("Is draft",
prDetailsMetadataKeyPadding),
))
}
if pr.Mergeable == mergeableConflicting {
metadata = append(metadata, fmt.Sprintf("- %s `true`", RightPadTrim("Has conflicts",
prDetailsMetadataKeyPadding),
))
}
if pr.Milestone != nil {
metadata = append(metadata, fmt.Sprintf("- %s %s", RightPadTrim("Milestone",
prDetailsMetadataKeyPadding),
pr.Milestone.Title,
))
}
if len(pr.LatestReviews.Nodes) > 0 {
reviews := make([]string, len(pr.LatestReviews.Nodes))
for i, r := range pr.LatestReviews.Nodes {
var state string
switch r.State {
case reviewPending:
state = "🟡"
case reviewCommented:
state = "💬"
case reviewChangesRequested:
state = "🔄"
case reviewApproved:
state = "✅"
case reviewDismissed:
state = "❌"
}
reviews[i] = fmt.Sprintf("`@%s` %s", r.Author.Login, state)
}
metadata = append(metadata, "\n---\n")
metadata = append(metadata, fmt.Sprintf("- %s %s",
RightPadTrim("Reviewed by", prDetailsMetadataKeyPadding),
strings.Join(reviews, ", "),
))
}
return fmt.Sprintf(`
## Metadata
%s`, strings.Join(metadata, "\n"))
}
func (pr prDetails) Description() string {
return fmt.Sprintf(`
## Description
%s`, pr.Body)
}
func (pr prDetails) Checks() string {
if len(pr.LastCommit.Nodes) == 0 {
return noChecksHeader
}
if pr.LastCommit.Nodes[0].Commit.StatusCheckRollup == nil {
return noChecksHeader
}
if len(pr.LastCommit.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes) == 0 {
return noChecksHeader
}
var checks []string
for _, n := range pr.LastCommit.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes {
switch n.Type {
case checkRunType:
checkName := RightPadTrim(n.CheckRun.Name, checkNamePadding)
if n.CheckRun.Conclusion != nil {
var conclusionMarker string
switch *n.CheckRun.Conclusion {
case checkConclusionStateSuccess:
conclusionMarker = " ✅"
case checkConclusionStateFailure, checkConclusionStateError:
conclusionMarker = " ❌"
}
checks = append(checks, fmt.Sprintf("- %s %s%s",
checkName,
RightPadTrim(fmt.Sprintf("`%s`", *n.CheckRun.Conclusion), statusConclusionPadding),
conclusionMarker,
))
} else {
checks = append(checks, fmt.Sprintf("- %s %s", checkName, n.CheckRun.Status))
}
case statusContextType:
var stateMarker string
switch n.StatusContext.State {
case statusStateSuccess:
stateMarker = " ✅"
case statusStateFailure, statusStateError:
stateMarker = " ❌"
}
checks = append(checks, fmt.Sprintf("- %s %s%s",
RightPadTrim(n.StatusContext.Context, checkNamePadding),
RightPadTrim(fmt.Sprintf("`%s`", n.StatusContext.State), statusConclusionPadding),
stateMarker,
))
}
}
if len(checks) == 0 {
return noChecksHeader
}
return fmt.Sprintf(`
## Checks
%s **%s**
%s`,
RightPadTrim("> Status of latest commit", checkNamePadding+2),
pr.LastCommit.Nodes[0].Commit.StatusCheckRollup.State,
strings.Join(checks, "\n"))
}
func (pr prDetails) References() string {
issues := make([]string, len(pr.IssueReferences.Nodes))
for i, iss := range pr.IssueReferences.Nodes {
issues[i] = fmt.Sprintf("- `#%d`: %s (%s)", iss.Number, iss.Title, iss.URL)
}
return fmt.Sprintf(`
## Referenced by
%s`, strings.Join(issues, "\n"))
}
func (pr prDetails) FilesChanged() string {
fc := make([]string, len(pr.Files.Nodes))
for i, f := range pr.Files.Nodes {
var additions string
var deletions string
if f.Additions > 0 {
additions = fmt.Sprintf(" `+%d`", f.Additions)
}
if f.Deletions > 0 {
deletions = fmt.Sprintf(" `-%d`", f.Deletions)
}
fc[i] = fmt.Sprintf("- %s%s%s", f.Path, additions, deletions)
}
return fmt.Sprintf(`
## Files changed
%s`, strings.Join(fc, "\n"))
}
func (pr prDetails) CommitsList() string {
var commitsStr string
commits := make([]string, len(pr.Commits.Nodes))
for i, c := range pr.Commits.Nodes {
hash := c.Commit.AbbreviatedOid
commits[i] = fmt.Sprintf("- `%s`: %s **(%s)** `<%s>`",
hash,
c.Commit.MessageHeadline,
humanize.Time(c.Commit.AuthoredDate),
c.Commit.Author.Name,
)
}
var commitsNumStr string
if len(pr.Commits.Nodes) < pr.Commits.TotalCount {
commitsNumStr = fmt.Sprintf(" (last %d out of %d)", len(pr.Commits.Nodes), pr.Commits.TotalCount)
}
commitsStr = fmt.Sprintf(`
## Commits%s
%s
`, commitsNumStr, strings.Join(commits, "\n"))
return commitsStr
}
func (pr prDetails) CommentsList() string {
comments := make([]string, len(pr.Comments.Nodes))
for i, c := range pr.Comments.Nodes {
comments[i] = fmt.Sprintf("`@%s` (%s):\n\n%s", c.Author.Login, humanize.Time(c.UpdatedAt), c.Body)
}
var commentsNumStr string
if len(pr.Comments.Nodes) < pr.Comments.TotalCount {
commentsNumStr = fmt.Sprintf(" (first %d out of %d)", len(pr.Comments.Nodes), pr.Comments.TotalCount)
}
return fmt.Sprintf(`
## Comments%s
%s
`, commentsNumStr, strings.Join(comments, "\n\n▬▬▬▬▬▬\n\n"))
}
func (repo Repo) Title() string {
return repo.Name
}
func (repo Repo) Description() string {
return repo.Owner
}
func (repo Repo) FilterValue() string {
return fmt.Sprintf("%s:::%s", repo.Owner, repo.Name)
}
func (prRes prResult) Title() string {
return prRes.title
}
func (prRes prResult) Description() string {
return prRes.description
}
func (prRes prResult) FilterValue() string {
return fmt.Sprintf("%d", prRes.pr.Number)
}
func (ir prTLItemResult) Title() string {
return ir.title
}
func (ir prTLItemResult) Description() string {
return ir.description
}
func (ir prTLItemResult) FilterValue() string {
return ir.title
}
================================================
FILE: ui/ui.go
================================================
package ui
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
ghapi "github.com/cli/go-gh/v2/pkg/api"
)
func RenderUI(ghClient *ghapi.GraphQLClient, config Config, mode Mode) error {
if len(os.Getenv("DEBUG")) > 0 {
f, err := tea.LogToFile("debug.log", "debug")
if err != nil {
fmt.Println("fatal:", err)
os.Exit(1)
}
defer f.Close()
}
p := tea.NewProgram(InitialModel(ghClient, config, mode))
_, err := p.Run()
return err
}
================================================
FILE: ui/update.go
================================================
package ui
import (
_ "embed"
"errors"
"fmt"
"strings"
"charm.land/bubbles/v2/list"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/dhth/prs/internal/utils"
)
const (
viewPortMoveLineCount = 5
couldntGetPRDetailsMsg = "Couldn't get repo/pr details. Inform @dhth on Github."
)
var (
//go:embed assets/help.md
helpStr string
ErrPRDetailsNotCached = errors.New("PR details were not saved")
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
m.message = ""
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.String() {
case "Q":
return m, tea.Quit
case "ctrl+c", "q", "esc":
switch m.activePane {
case repoListView:
if !m.repoChosen {
return m, tea.Quit
}
m.activePane = m.lastPane
case helpView:
m.activePane = m.lastPane
case prTLItemDetailView:
m.prTLItemDetailVP.GotoTop()
m.activePane = prTLListView
case prTLListView:
m.prTLList.ResetSelected()
m.activePane = prListView
case prDetailsView:
if m.lastPane == m.activePane {
m.activePane = m.secondLastActivePane
m.lastPane = prDetailsView
break
}
m.activePane = m.lastPane
m.lastPane = prDetailsView
case prListView:
if m.mode == RepoMode {
m.activePane = repoListView
m.repoChosen = false
break
}
return m, tea.Quit
default:
return m, tea.Quit
}
case "ctrl+r":
switch m.activePane {
case prListView:
switch m.mode {
case RepoMode:
cmds = append(cmds, fetchPRSForRepo(m.ghClient, m.repoOwner, m.repoName, m.config.PRCount))
case QueryMode:
cmds = append(cmds, fetchPRSFromQuery(m.ghClient, *m.config.Query, m.config.PRCount))
}
m.prsList.Title = fetchingPRsTitle
m.prsList.Styles.Title = m.prsList.Styles.Title.Background(lipgloss.Color(fetchingColor))
case prTLListView:
pr, ok := m.prsList.SelectedItem().(*prResult)
if !ok {
break
}
repoOwner := pr.pr.Repository.Owner.Login
repoName := pr.pr.Repository.Name
prNumber := pr.pr.Number
cmds = append(cmds, fetchPRTLItems(m.ghClient, repoOwner, repoName, prNumber, 100, true))
m.prTLList.Title = "fetching timeline..."
m.prTLList.Styles.Title = m.prTLList.Styles.Title.Background(lipgloss.Color(fetchingColor))
}
case "1":
if m.activePane != prTLListView && m.activePane != prTLItemDetailView && m.activePane != prDetailsView {
break
}
switch m.activePane {
case prDetailsView:
m.GoToPRDetailSection(0)
default:
m.activePane = prListView
}
case "enter":
switch m.activePane {
case prListView:
setTlCmd, ok := m.setTL()
if !ok {
m.message = couldntGetPRDetailsMsg
} else {
if setTlCmd != nil {
cmds = append(cmds, setTlCmd)
}
}
case prTLListView:
item, ok := m.prTLList.SelectedItem().(*prTLItemResult)
if !ok {
break
}
if item.item.Type != tlItemPRReview {
break
}
if len(item.item.PullRequestReview.Comments.Nodes) == 0 {
break
}
m.setPRReviewCmt(item.item, 0)
m.prRevCurCmtNum = 0
m.activePane = prTLItemDetailView
case repoListView:
selected := m.repoList.SelectedItem()
if selected != nil {
cmds = append(cmds, chooseRepo(selected.FilterValue()))
}
}
case "2":
if m.activePane != prListView && m.activePane != prTLItemDetailView && m.activePane != prDetailsView {
break
}
switch m.activePane {
case prDetailsView:
m.GoToPRDetailSection(1)
default:
setTlCmd, ok := m.setTL()
if !ok {
m.message = "Could't get repo/pr details. Inform @dhth on github."
break
}
if setTlCmd != nil {
cmds = append(cmds, setTlCmd)
}
}
case "3":
if m.activePane != prTLListView && m.activePane != prDetailsView {
break
}
switch m.activePane {
case prDetailsView:
m.GoToPRDetailSection(2)
default:
tlItem, ok := m.prTLList.SelectedItem().(*prTLItemResult)
if !ok {
break
}
if tlItem.item.Type != tlItemPRReview {
break
}
if len(tlItem.item.PullRequestReview.Comments.Nodes) == 0 {
break
}
m.setPRReviewCmt(tlItem.item, 0)
m.activePane = prTLItemDetailView
}
case "4":
if m.activePane != prDetailsView {
break
}
m.GoToPRDetailSection(3)
case "5":
if m.activePane != prDetailsView {
break
}
m.GoToPRDetailSection(4)
case "6":
if m.activePane != prDetailsView {
break
}
m.GoToPRDetailSection(5)
case "j", "down":
if m.activePane != prTLItemDetailView && m.activePane != helpView && m.activePane != prDetailsView {
break
}
switch m.activePane {
case prTLItemDetailView:
if m.prTLItemDetailVP.AtBottom() {
break
}
m.prTLItemDetailVP.ScrollDown(viewPortMoveLineCount)
case prDetailsView:
if m.prDetailsVP.AtBottom() {
break
}
m.prDetailsVP.ScrollUp(viewPortMoveLineCount)
case helpView:
if m.helpVP.AtBottom() {
break
}
m.helpVP.ScrollDown(viewPortMoveLineCount)
}
case "k", "up":
if m.activePane != prTLItemDetailView && m.activePane != helpView && m.activePane != prDetailsView {
break
}
switch m.activePane {
case prTLItemDetailView:
if m.prTLItemDetailVP.AtTop() {
break
}
m.prTLItemDetailVP.ScrollUp(viewPortMoveLineCount)
case prDetailsView:
if m.prDetailsVP.AtTop() {
break
}
m.prDetailsVP.ScrollUp(viewPortMoveLineCount)
case helpView:
if m.helpVP.AtTop() {
break
}
m.helpVP.ScrollUp(viewPortMoveLineCount)
}
case "tab", "shift+tab":
if m.activePane == helpView || m.activePane == prDetailsView {
break
}
if m.activePane == prListView {
setTlCmd, ok := m.setTL()
if !ok {
m.message = "Could't get repo/pr details. Inform @dhth on github."
} else {
if setTlCmd != nil {
cmds = append(cmds, setTlCmd)
}
}
} else {
m.activePane = prListView
}
case "ctrl+s":
if m.mode == RepoMode {
if m.activePane != repoListView {
m.lastPane = m.activePane
m.activePane = repoListView
} else {
m.activePane = m.lastPane
}
}
case "ctrl+b":
switch m.activePane {
case prListView, prDetailsView:
pr, ok := m.prsList.SelectedItem().(*prResult)
if !ok {
break
}
cmds = append(cmds, openURLInBrowser(pr.pr.URL))
case prTLListView, prTLItemDetailView:
item, ok := m.prTLList.SelectedItem().(*prTLItemResult)
if !ok {
break
}
switch item.item.Type {
case tlItemPRCommit:
cmds = append(cmds, openURLInBrowser(item.item.PullRequestCommit.URL))
case tlItemHeadRefForcePushed:
cmds = append(cmds, openURLInBrowser(item.item.HeadRefForcePushed.AfterCommit.URL))
case tlItemPRReview:
cmds = append(cmds, openURLInBrowser(item.item.PullRequestReview.URL))
case tlItemMergedEvent:
cmds = append(cmds, openURLInBrowser(item.item.MergedEvent.URL))
}
}
case "ctrl+d":
if m.activePane != prListView && m.activePane != prTLListView {
break
}
pr, ok := m.prsList.SelectedItem().(*prResult)
if !ok {
break
}
cmds = append(cmds, showDiff(pr.pr.Repository.Owner.Login,
pr.pr.Repository.Name,
pr.pr.Number))
case "ctrl+v":
if m.activePane == helpView {
break
}
pr, ok := m.prsList.SelectedItem().(*prResult)
if !ok {
break
}
cmds = append(cmds, showPR(pr.pr.Repository.Owner.Login,
pr.pr.Repository.Name,
pr.pr.Number))
case "g":
switch m.activePane {
case prTLItemDetailView:
m.prTLItemDetailVP.GotoTop()
case prDetailsView:
m.prDetailsVP.GotoTop()
case helpView:
m.helpVP.GotoTop()
}
case "G":
switch m.activePane {
case prTLItemDetailView:
m.prTLItemDetailVP.GotoBottom()
case prDetailsView:
m.prDetailsVP.GotoBottom()
case helpView:
m.helpVP.GotoBottom()
}
case "K", "[":
if m.activePane != prDetailsView {
break
}
m.prsList.CursorUp()
prRes, ok := m.prsList.SelectedItem().(*prResult)
if !ok {
break
}
prDetails, ok := m.prDetailsCache[prRes.identifier]
if !ok {
break
}
var section uint
lastSection, ok := m.prDetailsCurSectionCache[prRes.identifier]
if ok {
section = lastSection
} else {
section = 0
}
m.setPRDetailsContent(prDetails, PRDetailsSectionList[section])
m.prDetailsCurrentSection = section
case "J", "]":
if m.activePane != prDetailsView {
break
}
m.prsList.CursorDown()
prRes, ok := m.prsList.SelectedItem().(*prResult)
if !ok {
break
}
prDetails, ok := m.prDetailsCache[prRes.identifier]
if !ok {
break
}
var section uint
lastSection, ok := m.prDetailsCurSectionCache[prRes.identifier]
if ok {
section = lastSection
} else {
section = 0
}
m.setPRDetailsContent(prDetails, PRDetailsSectionList[section])
m.prDetailsCurrentSection = section
case "d":
if m.activePane != prListView && m.activePane != prDetailsView && m.activePane != prTLListView && m.activePane != prTLItemDetailView {
break
}
if m.activePane == prDetailsView {
m.activePane = m.lastPane
break
}
prRes, ok := m.prsList.SelectedItem().(*prResult)
if !ok {
break
}
prDetails, ok := m.prDetailsCache[prRes.identifier]
if !ok {
m.message = "PR details were not retrieved"
break
}
var section uint
lastSection, ok := m.prDetailsCurSectionCache[prRes.identifier]
if ok {
section = lastSection
} else {
section = 0
}
m.setPRDetailsContent(prDetails, PRDetailsSectionList[section])
m.prDetailsCurrentSection = section
m.prDetailsVP.GotoTop()
m.lastPane = m.activePane
m.activePane = prDetailsView
case "l", "n", "right":
if m.activePane != prDetailsView && m.activePane != prTLItemDetailView {
break
}
switch m.activePane {
case prDetailsView:
prRes, ok := m.prsList.SelectedItem().(*prResult)
if !ok {
break
}
prDetails, ok := m.prDetailsCache[prRes.identifier]
if !ok {
break
}
nextSectionFound := false
var nextSection uint
if m.prDetailsCurrentSection == uint(len(PRDetailsSectionList)-1) {
nextSection = 0
} else {
nextSection = m.prDetailsCurrentSection + 1
}
for {
switch nextSection {
case 0:
nextSectionFound = true
case 1:
if prDetails.Body != "" {
nextSectionFound = true
}
case 2:
// this may still lead to no status checks being shown
// but the probability of that happening is pretty low
if len(prDetails.LastCommit.Nodes) > 0 && prDetails.LastCommit.Nodes[0].Commit.StatusCheckRollup != nil {
nextSectionFound = true
}
case 3:
if len(prDetails.IssueReferences.Nodes) > 0 {
nextSectionFound = true
}
case 4:
if len(prDetails.Files.Nodes) > 0 {
nextSectionFound = true
}
case 5:
if len(prDetails.Commits.Nodes) > 0 {
nextSectionFound = true
}
case 6:
if len(prDetails.Comments.Nodes) > 0 {
nextSectionFound = true
} else {
nextSection = 0
nextSectionFound = true
}
}
if nextSectionFound {
break
}
nextSection++
}
if !nextSectionFound {
break
}
if nextSection > uint(len(PRDetailsSectionList)-1) {
m.message = "Something went wrong"
break
}
m.setPRDetailsContent(prDetails, PRDetailsSectionList[nextSection])
m.prDetailsCurSectionCache[prRes.identifier] = nextSection
m.prDetailsCurrentSection = nextSection
case prTLItemDetailView:
tlItem, ok := m.prTLList.SelectedItem().(*prTLItemResult)
if !ok {
break
}
if tlItem.item.Type != tlItemPRReview {
break
}
if len(tlItem.item.PullRequestReview.Comments.Nodes) <= 1 {
break
}
nextCommentIndex := m.prRevCurCmtNum + 1
if nextCommentIndex > uint(len(tlItem.item.PullRequestReview.Comments.Nodes))-1 {
nextCommentIndex = 0
}
m.setPRReviewCmt(tlItem.item, nextCommentIndex)
m.prRevCurCmtNum = nextCommentIndex
}
case "h", "N", "left":
if m.activePane != prDetailsView && m.activePane != prTLItemDetailView {
break
}
switch m.activePane {
case prDetailsView:
prRes, ok := m.prsList.SelectedItem().(*prResult)
if !ok {
break
}
prDetails, ok := m.prDetailsCache[prRes.identifier]
if !ok {
break
}
prevSectionFound := false
var prevSection uint
if m.prDetailsCurrentSection == 0 {
prevSection = uint(len(PRDetailsSectionList) - 1)
} else {
prevSection = m.prDetailsCurrentSection - 1
}
for {
switch prevSection {
case 0:
prevSectionFound = true
case 1:
if prDetails.Body != "" {
prevSectionFound = true
}
case 2:
if len(prDetails.LastCommit.Nodes) > 0 && prDetails.LastCommit.Nodes[0].Commit.StatusCheckRollup != nil {
prevSectionFound = true
}
case 3:
if len(prDetails.IssueReferences.Nodes) > 0 {
prevSectionFound = true
}
case 4:
if len(prDetails.Files.Nodes) > 0 {
prevSectionFound = true
}
case 5:
if len(prDetails.Commits.Nodes) > 0 {
prevSectionFound = true
}
case 6:
if len(prDetails.Comments.Nodes) > 0 {
prevSectionFound = true
}
}
if prevSectionFound {
break
}
prevSection--
}
m.setPRDetailsContent(prDetails, PRDetailsSectionList[prevSection])
m.prDetailsCurSectionCache[prRes.identifier] = prevSection
m.prDetailsCurrentSection = prevSection
case prTLItemDetailView:
tlItem, ok := m.prTLList.SelectedItem().(*prTLItemResult)
if !ok {
break
}
if tlItem.item.Type != tlItemPRReview {
break
}
if len(tlItem.item.PullRequestReview.Comments.Nodes) <= 1 {
break
}
var prevCommentIndex uint
if m.prRevCurCmtNum == 0 {
prevCommentIndex = uint(len(tlItem.item.PullRequestReview.Comments.Nodes) - 1)
} else {
prevCommentIndex = m.prRevCurCmtNum - 1
}
m.setPRReviewCmt(tlItem.item, prevCommentIndex)
m.prRevCurCmtNum = prevCommentIndex
}
case "?":
if m.activePane == helpView {
m.activePane = m.lastPane
break
}
if m.activePane == prDetailsView {
m.secondLastActivePane = m.lastPane
}
m.lastPane = m.activePane
m.activePane = helpView
}
case hideHelpMsg:
m.showHelp = false
case tea.WindowSizeMsg:
w, h := listStyle.GetFrameSize()
m.terminalDetails.width = msg.Width
if m.mode == RepoMode {
m.repoList.SetHeight(msg.Height - h - 2)
m.repoList.SetWidth(msg.Width - w)
}
m.prsList.SetHeight(msg.Height - h - 2)
m.prsList.SetWidth(msg.Width - w)
m.prTLList.SetHeight(msg.Height - h - 2)
m.prTLList.SetWidth(msg.Width - w)
if !m.prTLItemDetailVPReady {
m.prTLItemDetailVP = viewport.New(
viewport.WithWidth(msg.Width-2),
viewport.WithHeight(msg.Height-7),
)
m.prTLItemDetailVPReady = true
m.prTLItemDetailVP.KeyMap.HalfPageDown.SetKeys("ctrl+d")
} else {
m.prTLItemDetailVP.SetWidth(msg.Width - 2)
m.prTLItemDetailVP.SetHeight(msg.Height - 7)
}
if !m.prDetailsVPReady {
m.prDetailsVP = viewport.New(
viewport.WithWidth(msg.Width-2),
viewport.WithHeight(msg.Height-7),
)
m.prDetailsVPReady = true
m.prDetailsVP.KeyMap.HalfPageDown.SetKeys("ctrl+d")
} else {
m.prDetailsVP.SetWidth(msg.Width - 2)
m.prDetailsVP.SetHeight(msg.Height - 7)
}
vpWrap := min((msg.Width - 4), viewPortWrapUpperLimit)
m.mdRenderer, _ = utils.GetMarkDownRenderer(vpWrap)
helpToRender := helpStr
if m.mdRenderer != nil {
helpStrGl, err := m.mdRenderer.Render(helpStr)
if err != nil {
break
}
helpToRender = helpStrGl
}
if !m.helpVPReady {
m.helpVP = viewport.New(
viewport.WithWidth(msg.Width),
viewport.WithHeight(msg.Height-7),
)
m.helpVP.SetContent(helpToRender)
m.helpVPReady = true
} else {
m.helpVP.SetWidth(msg.Width)
m.helpVP.SetHeight(msg.Height - 7)
}
prs := make([]list.Item, len(m.prCache))
for i := range m.prCache {
m.prCache[i].title = getPRTitle(m.prCache[i].pr)
m.prCache[i].description = getPRDesc(m.prCache[i].pr, m.mode, m.terminalDetails)
prs[i] = m.prCache[i]
}
m.prsList.SetItems(prs)
if m.activePane == prTLListView {
m.setTL()
}
case repoChosenMsg:
repoDetails := strings.Split(msg.repo, ":::")
if len(repoDetails) != 2 {
m.message = "Something went horribly wrong. Let @dhth know about this failure."
} else {
m.repoChosen = true
m.prsList.Title = fetchingPRsTitle
m.prsList.Styles.Title = m.prsList.Styles.Title.Background(lipgloss.Color(fetchingColor))
m.repoOwner = repoDetails[0]
m.repoName = repoDetails[1]
m.activePane = prListView
m.prsList.ResetSelected()
m.prTLList.ResetSelected()
cmds = append(cmds, fetchPRSForRepo(m.ghClient, m.repoOwner, m.repoName, m.config.PRCount))
}
case prsFetchedMsg:
if msg.err != nil {
m.message = msg.err.Error()
m.prsList.Title = "error"
break
}
prs := make([]list.Item, len(msg.prs))
prResults := make([]*prResult, len(msg.prs))
m.prDetailsCurSectionCache = make(map[string]uint)
for i, pr := range msg.prs {
prResults[i] = &prResult{
pr: &pr,
title: getPRTitle(&pr),
description: getPRDesc(&pr, m.mode, m.terminalDetails),
identifier: fmt.Sprintf("%s/%s:%d", pr.Repository.Owner.Login, pr.Repository.Name, pr.Number),
}
prs[i] = prResults[i]
}
m.prCache = prResults
m.prsList.SetItems(prs)
switch m.mode {
case RepoMode:
m.prsList.Title = fmt.Sprintf("PRs (%s)", m.repoName)
case QueryMode:
m.prsList.Title = "Results"
}
m.prsList.ResetSelected()
m.prsList.Styles.Title = m.prsList.Styles.Title.Background(lipgloss.Color(prListColor))
for _, pr := range msg.prs {
cmds = append(cmds, fetchPRTLItems(m.ghClient,
pr.Repository.Owner.Login,
pr.Repository.Name,
pr.Number,
100,
false,
))
cmds = append(cmds, fetchPRMetadata(m.ghClient,
pr.Repository.Owner.Login,
pr.Repository.Name,
pr.Number,
))
}
case reviewPRsFetchedMsg:
if msg.err != nil {
m.message = msg.err.Error()
break
}
prs := make([]list.Item, len(msg.prs))
prResults := make([]*prResult, len(msg.prs))
m.prDetailsCurSectionCache = make(map[string]uint)
for i, pr := range msg.prs {
prResults[i] = &prResult{
pr: &pr,
title: getPRTitle(&pr),
description: getPRDesc(&pr, m.mode, m.terminalDetails),
identifier: fmt.Sprintf("%s/%s:%d", pr.Repository.Owner.Login, pr.Repository.Name, pr.Number),
}
prs[i] = prResults[i]
}
m.prCache = prResults
m.prsList.SetItems(prs)
m.prsList.ResetSelected()
m.prsList.Title = "Open PRs requesting your review"
m.prsList.Styles.Title = m.prsList.Styles.Title.Background(lipgloss.Color(prListColor))
if len(msg.prs) > 0 {
for _, pr := range msg.prs {
cmds = append(cmds, fetchPRTLItems(m.ghClient, pr.Repository.Owner.Login, pr.Repository.Name, pr.Number, 100, false))
cmds = append(cmds, fetchPRMetadata(m.ghClient,
pr.Repository.Owner.Login,
pr.Repository.Name,
pr.Number,
))
}
}
case authoredPRsFetchedMsg:
if msg.err != nil {
m.message = msg.err.Error()
break
}
prs := make([]list.Item, len(msg.prs))
prResults := make([]*prResult, len(msg.prs))
m.prDetailsCurSectionCache = make(map[string]uint)
for i, pr := range msg.prs {
prResults[i] = &prResult{
pr: &pr,
title: getPRTitle(&pr),
description: getPRDesc(&pr, m.mode, m.terminalDetails),
identifier: fmt.Sprintf("%s/%s:%d", pr.Repository.Owner.Login, pr.Repository.Name, pr.Number),
}
prs[i] = prResults[i]
}
m.prCache = prResults
m.prsList.SetItems(prs)
m.prsList.Title = "Open PRs authored by you"
m.prsList.ResetSelected()
m.prsList.Styles.Title = m.prsList.Styles.Title.Background(lipgloss.Color(prListColor))
if len(msg.prs) > 0 {
for _, pr := range msg.prs {
cmds = append(cmds, fetchPRTLItems(m.ghClient, pr.Repository.Owner.Login, pr.Repository.Name, pr.Number, 100, false))
cmds = append(cmds, fetchPRMetadata(m.ghClient,
pr.Repository.Owner.Login,
pr.Repository.Name,
pr.Number,
))
}
}
case prMetadataFetchedMsg:
if msg.err != nil {
m.message = msg.err.Error()
break
}
m.prDetailsCache[fmt.Sprintf("%s/%s:%d", msg.repoOwner, msg.repoName, msg.prNumber)] = msg.metadata
case prTLFetchedMsg:
if msg.err != nil {
m.message = msg.err.Error()
break
}
tlItemsResult := make([]*prTLItemResult, len(msg.prTLItems))
for i, item := range msg.prTLItems {
tlItemsResult[i] = &prTLItemResult{
item: &item,
title: getPRTLItemTitle(&item),
description: getPRTLItemDesc(&item),
}
}
m.prTLCache[fmt.Sprintf("%s/%s:%d", msg.repoOwner, msg.repoName, msg.prNumber)] = tlItemsResult
if msg.setItems {
prTLItems := make([]list.Item, len(msg.prTLItems))
for i, result := range tlItemsResult {
prTLItems[i] = result
}
m.prTLList.SetItems(prTLItems)
m.prTLList.Title = fmt.Sprintf("PR #%d Timeline", msg.prNumber)
m.prTLList.Styles.Title = m.prTLList.Styles.Title.Background(lipgloss.Color(prTLListColor))
m.activePane = prTLListView
}
m.prTLList.ResetSelected()
case urlOpenedinBrowserMsg:
if msg.err != nil {
m.message = fmt.Sprintf("Error opening url: %s", msg.err.Error())
}
case prDiffDoneMsg:
if msg.err != nil {
m.message = fmt.Sprintf("Error opening diff (is gh installed?): %s", msg.err.Error())
}
case prViewDoneMsg:
if msg.err != nil {
m.message = fmt.Sprintf("Error showing PR details (is gh installed?): %s", msg.err.Error())
}
}
switch m.activePane {
case prListView:
m.prsList, cmd = m.prsList.Update(msg)
cmds = append(cmds, cmd)
case prTLListView:
m.prTLList, cmd = m.prTLList.Update(msg)
cmds = append(cmds, cmd)
case prDetailsView:
m.prDetailsVP, cmd = m.prDetailsVP.Update(msg)
cmds = append(cmds, cmd)
case prTLItemDetailView:
m.prTLItemDetailVP, cmd = m.prTLItemDetailVP.Update(msg)
cmds = append(cmds, cmd)
case repoListView:
m.repoList, cmd = m.repoList.Update(msg)
cmds = append(cmds, cmd)
case helpView:
m.helpVP, cmd = m.helpVP.Update(msg)
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
func (m *Model) setTL() (tea.Cmd, bool) {
var cmd tea.Cmd
var repoOwner, repoName string
var prNumber int
prRes, prOk := m.prsList.SelectedItem().(*prResult)
if !prOk {
return nil, false
}
repoOwner = prRes.pr.Repository.Owner.Login
repoName = prRes.pr.Repository.Name
prNumber = prRes.pr.Number
tlFromCache, ok := m.prTLCache[prRes.identifier]
if !ok {
cmd = fetchPRTLItems(m.ghClient, repoOwner, repoName, prNumber, 100, true)
return cmd, true
}
tlItems := make([]list.Item, len(tlFromCache))
// this list always get rerendered as it seems to be preferrable over recomputing the string rep of every item in
// every list in m.prTLCache when the terminal window is resized
for i, result := range tlFromCache {
title := getPRTLItemTitle(result.item)
description := getPRTLItemDesc(result.item)
result.title = title
result.description = description
tlItems[i] = result
}
m.prTLList.SetItems(tlItems)
m.prTLList.Title = fmt.Sprintf("PR #%d Timeline", prNumber)
m.activePane = prTLListView
return nil, true
}
================================================
FILE: ui/utils.go
================================================
package ui
import (
"strings"
)
func RightPadTrim(s string, length int) string {
if len(s) >= length {
if length > 3 {
return s[:length-3] + "..."
}
return s[:length]
}
return s + strings.Repeat(" ", length-len(s))
}
func Trim(s string, length int) string {
if len(s) >= length {
if length > 3 {
return s[:length-3] + "..."
}
return s[:length]
}
return s
}
func getFracInt(num int, frac float32) int {
return int(float32(num) * frac)
}
================================================
FILE: ui/view.go
================================================
package ui
import (
"fmt"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
const (
viewPortWrapUpperLimit = 160
vpNotReadyMsg = "Initializing..."
)
func (m Model) View() tea.View {
var content string
var footer string
var statusBar string
if m.message != "" {
statusBar = RightPadTrim(m.message, m.terminalDetails.width)
}
switch m.activePane {
case prListView:
content = listStyle.Render(m.prsList.View())
case prTLListView:
content = listStyle.Render(m.prTLList.View())
case repoListView:
content = listStyle.Render(m.repoList.View())
case prDetailsView:
if !m.prTLItemDetailVPReady {
content = vpNotReadyMsg
} else {
content = viewPortStyle.Render(fmt.Sprintf(" %s\n\n%s\n",
prDetailsTitleStyle.Render(m.prDetailsTitle),
m.prDetailsVP.View()))
}
case prTLItemDetailView:
var prRevCmtsVP string
if !m.prTLItemDetailVPReady {
prRevCmtsVP = vpNotReadyMsg
} else {
prRevCmtsVP = viewPortStyle.Render(fmt.Sprintf(" %s\n\n%s\n",
helpVPTitleStyle.Render(m.prTLItemDetailTitle),
m.prTLItemDetailVP.View()))
}
content = prRevCmtsVP
case helpView:
var helpVP string
if !m.helpVPReady {
helpVP = vpNotReadyMsg
} else {
helpVP = viewPortStyle.Render(fmt.Sprintf(" %s\n\n%s\n",
helpVPTitleStyle.Render("Help"),
m.helpVP.View()))
}
content = helpVP
}
var helpMsg string
if m.showHelp {
helpMsg = helpMsgStyle.Render("Press ? for help")
}
footerStr := fmt.Sprintf("%s%s",
toolNameStyle.Render("prs"),
helpMsg,
)
footer = footerStyle.Render(footerStr)
rendered := lipgloss.JoinVertical(lipgloss.Left,
content,
statusBar,
footer,
)
v := tea.NewView(rendered)
v.AltScreen = true
return v
}
================================================
FILE: yamlfmt.yml
================================================
formatter:
retain_line_breaks_single: true