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

build status vuln check Latest release Commits since latest release

`prs` lets you stay updated on pull requests from your terminal.

Usage

[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 ![Screen 1](https://tools.dhruvs.space/images/prs/v1-0-0/prs-1.png) ### PR Timeline List View ![Screen 2](https://tools.dhruvs.space/images/prs/v1-0-0/prs-2.png) ### PR Timeline Item Detail View ![Screen 3](https://tools.dhruvs.space/images/prs/v1-0-0/prs-3.png) ### PR Details View ![Screen 4](https://tools.dhruvs.space/images/prs/v1-0-0/prs-4.png) ![Screen 5](https://tools.dhruvs.space/images/prs/v1-0-0/prs-5.png) ![Screen 6](https://tools.dhruvs.space/images/prs/v1-0-0/prs-6.png) ![Screen 7](https://tools.dhruvs.space/images/prs/v1-0-0/prs-7.png) **[`^ 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