Skip to content

Commit 1a7d696

Browse files
committed
Initial commit
Go CLI tool that shows PRs awaiting your review across a GitHub org, with interactive TUI and plain text output modes.
0 parents  commit 1a7d696

17 files changed

Lines changed: 1383 additions & 0 deletions

.claude/settings.local.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"includeCoAuthoredBy": false,
3+
"permissions": {
4+
"allow": [
5+
"WebFetch(domain:github.com)",
6+
"Bash(gh api repos/candoo-tech/platform/contents/.devcontainer/devcontainer.json --jq '.content' | base64 -d 2>/dev/null || gh api repos/candoo-tech/platform/contents/.devcontainer/devcontainer.json 2>&1)",
7+
"Bash(go version:*)",
8+
"Bash(gh:*)",
9+
"Bash(go env:*)",
10+
"Bash(go doc:*)",
11+
"Bash(python3:*)",
12+
"Bash(git init:*)",
13+
"Bash(go mod init:*)",
14+
"Bash(go get:*)",
15+
"Bash(go build:*)",
16+
"Bash(go test:*)",
17+
"Bash(./pr-patrol:*)",
18+
"Bash(echo:*)",
19+
"Bash(git remote add:*)",
20+
"Bash(git branch:*)",
21+
"Bash(git status:*)",
22+
"Bash(git add:*)",
23+
"Bash(git commit:*)",
24+
"Bash(git push:*)",
25+
"Bash(ls:*)",
26+
"Bash(go list:*)",
27+
"Bash(go mod graph:*)"
28+
]
29+
}
30+
}

.devcontainer/devcontainer.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
2+
// README at: https://github.com/devcontainers/templates/tree/main/src/go .
3+
{
4+
"name": "Go",
5+
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
6+
"image": "mcr.microsoft.com/devcontainers/go:2-1.25-trixie",
7+
8+
// Features to add to the dev container. More info: https://containers.dev/features.
9+
"features": {
10+
"ghcr.io/devcontainers/features/node:1": {},
11+
"ghcr.io/devcontainers/features/github-cli:1": {},
12+
"ghcr.io/anthropics/devcontainer-features/claude-code:1": {}
13+
},
14+
15+
// Mount named volumes so Claude and GitHub CLI auth persist across rebuilds
16+
// and are shared globally across all devcontainer projects.
17+
"mounts": [
18+
"source=global-claude-code-config,target=/home/vscode/.claude,type=volume",
19+
"source=global-gh-cli-config,target=/home/vscode/.config/gh,type=volume"
20+
],
21+
22+
"containerEnv": {
23+
"CLAUDE_CONFIG_DIR": "/home/vscode/.claude"
24+
}
25+
}

.github/workflows/release.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*'
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
release:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- uses: actions/setup-go@v5
18+
with:
19+
go-version: '1.25'
20+
21+
- name: Run tests
22+
run: go test ./...
23+
24+
- name: Build binaries
25+
run: |
26+
mkdir -p dist
27+
GOOS=darwin GOARCH=arm64 go build -o dist/pr-patrol-darwin-arm64 .
28+
GOOS=darwin GOARCH=amd64 go build -o dist/pr-patrol-darwin-amd64 .
29+
GOOS=linux GOARCH=amd64 go build -o dist/pr-patrol-linux-amd64 .
30+
GOOS=linux GOARCH=arm64 go build -o dist/pr-patrol-linux-arm64 .
31+
GOOS=windows GOARCH=amd64 go build -o dist/pr-patrol-windows-amd64.exe .
32+
33+
- name: Create release
34+
uses: softprops/action-gh-release@v2
35+
with:
36+
generate_release_notes: true
37+
files: dist/*

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pr-patrol

CLAUDE.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Commands
6+
7+
```bash
8+
go build . # Build binary
9+
go test ./... # Run all tests
10+
go test -v ./... # Verbose test output
11+
go test -run TestClassify_New # Run a single test by name
12+
go test -run "TestClassify_" # Run tests matching a pattern
13+
go run . --org <org> # Run directly (requires gh CLI auth)
14+
```
15+
16+
## Architecture
17+
18+
Single-package Go CLI (`package main`) that fetches open PRs across a GitHub org and shows which ones need your review.
19+
20+
**Data flow:** `main.go` orchestrates a linear pipeline:
21+
22+
1. **github.go** — Calls GitHub GraphQL API via `gh` CLI subprocess. Fetches current user (`fetchCurrentUser`) and paginated open PRs (`fetchOpenPRs`). Defines `PRNode`, `ReviewNode`, `CommentNode`, `CommitNode` types matching the GraphQL response shape.
23+
24+
2. **classify.go**`classify()` determines a single PR's `ReviewState` (NEW/CMT/DIS/STL) based on your reviews, comments, and latest commit timestamps. `classifyAll()` filters out current reviews and self-authored PRs, then sorts by state priority then recency. Returns `[]ClassifiedPR`.
25+
26+
3. **tui.go** — BubbleTea interactive terminal UI. The `model` struct holds classified PRs, cursor position, session-local dismissals, and dynamic column widths. Opens PRs in browser via `gh pr view --web`.
27+
28+
4. **plain.go**`renderPlain()` writes tabular text to an `io.Writer` for scripting/piping.
29+
30+
## External Dependency
31+
32+
All GitHub API access goes through the `gh` CLI as a subprocess (not direct HTTP). The tool requires `gh` to be installed and authenticated (`gh auth login`).
33+
34+
## Testing Patterns
35+
36+
Tests use Go's standard `testing` package with a builder pattern for constructing test PRs:
37+
38+
```go
39+
pr := makePR(
40+
withReview("me", "APPROVED", reviewTime),
41+
withLastCommit(commitTime),
42+
)
43+
state, include := classify(pr, "me")
44+
```
45+
46+
Builders: `makePR()`, `withReview()`, `withComment()`, `withAuthor()`, `withLastCommit()` — defined in `classify_test.go`.
47+
48+
TUI tests use a `sendKey()` helper to simulate keyboard input on the BubbleTea model.

README.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# pr-patrol
2+
3+
See every PR waiting for you across an entire GitHub org — without clicking through dozens of repos.
4+
5+
pr-patrol fetches all open pull requests in an organization and classifies each one based on **your** review state — whether you've never seen it, left a comment, had your review dismissed, or the author pushed new commits since you last reviewed. PRs where your review is still current are hidden, so you only see what needs your attention.
6+
7+
```
8+
$ pr-patrol --org kubernetes --plain
9+
[NEW] kops#18008 justinsb tests/ai-conformance: update test job for DRA v1
10+
[NEW] website#54596 nojnhuh [WIP] Docs update for KEP-5729: DRA ResourceClaim Support
11+
[NEW] kubernetes#137192 kannon92 promote two test cases for ObservedGeneration to conformance
12+
[STL] website#54321 liggitt Update API reference docs for v1.33
13+
[CMT] enhancements#5900 dims KEP-4639: Update OCI VolumeSource
14+
[DIS] kubernetes#136800 aojea Fix endpoint reconciler for dual-stack
15+
```
16+
17+
## Review States
18+
19+
| Tag | Meaning |
20+
|-----|---------|
21+
| `[NEW]` | You've never reviewed or commented on this PR |
22+
| `[CMT]` | You've left comments but haven't submitted a formal review |
23+
| `[DIS]` | Your review was dismissed by the PR author or a maintainer |
24+
| `[STL]` | You reviewed, but new commits have been pushed since |
25+
26+
PRs where your review is still current (approved/changes-requested and no new commits) are automatically hidden.
27+
28+
## Install
29+
30+
Download a binary from [Releases](https://github.com/agrieser/pr-patrol/releases), or build from source:
31+
32+
```
33+
go install github.com/agrieser/pr-patrol@latest
34+
```
35+
36+
Requires the [GitHub CLI](https://cli.github.com) (`gh`) to be installed and authenticated (`gh auth login`).
37+
38+
## Usage
39+
40+
```
41+
pr-patrol --org mycompany
42+
```
43+
44+
This launches an interactive TUI with colored tags and keyboard navigation. Use `--plain` for scriptable text output:
45+
46+
```
47+
# Count PRs awaiting your review
48+
pr-patrol --org mycompany --plain | wc -l
49+
50+
# Set your org once
51+
export GITHUB_ORG=mycompany
52+
pr-patrol
53+
```
54+
55+
### Flags
56+
57+
| Flag | Env Var | Description |
58+
|------|---------|-------------|
59+
| `--org` | `GITHUB_ORG` | GitHub organization (required) |
60+
| `--plain` | | Plain text output, no TUI |
61+
| `--self` | | Include your own PRs (excluded by default) |
62+
63+
### TUI Keys
64+
65+
| Key | Action |
66+
|-----|--------|
67+
| `j` / `k` / `` / `` | Navigate |
68+
| `Enter` | Open PR in browser |
69+
| `d` | Dismiss (session only) |
70+
| `q` | Quit |

classify.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package main
2+
3+
import (
4+
"sort"
5+
"time"
6+
)
7+
8+
type ReviewState string
9+
10+
const (
11+
StateNew ReviewState = "NEW"
12+
StateCommented ReviewState = "CMT"
13+
StateDismissed ReviewState = "DIS"
14+
StateStale ReviewState = "STL"
15+
)
16+
17+
type ClassifiedPR struct {
18+
State ReviewState
19+
RepoName string
20+
RepoFullName string
21+
Number int
22+
Title string
23+
Author string
24+
URL string
25+
CreatedAt time.Time
26+
}
27+
28+
var statePriority = map[ReviewState]int{
29+
StateNew: 0,
30+
StateCommented: 1,
31+
StateDismissed: 2,
32+
StateStale: 3,
33+
}
34+
35+
func classify(pr PRNode, me string) (ReviewState, bool) {
36+
// Collect my submitted reviews (skip PENDING)
37+
var myReviews []ReviewNode
38+
for _, r := range pr.Reviews.Nodes {
39+
if r.Author.Login == "" || r.Author.Login != me {
40+
continue
41+
}
42+
if r.State == "PENDING" {
43+
continue
44+
}
45+
myReviews = append(myReviews, r)
46+
}
47+
48+
// Check if I've commented (issue-level comments)
49+
hasComment := false
50+
for _, c := range pr.Comments.Nodes {
51+
if c.Author.Login == me {
52+
hasComment = true
53+
break
54+
}
55+
}
56+
57+
// No reviews from me
58+
if len(myReviews) == 0 {
59+
if hasComment {
60+
return StateCommented, true
61+
}
62+
return StateNew, true
63+
}
64+
65+
// Look at my most recent review
66+
lastReview := myReviews[len(myReviews)-1]
67+
68+
if lastReview.State == "DISMISSED" {
69+
return StateDismissed, true
70+
}
71+
72+
// Check if stale: latest commit is newer than my last review
73+
if len(pr.Commits.Nodes) > 0 {
74+
lastCommitDate := pr.Commits.Nodes[0].Commit.CommittedDate
75+
if lastCommitDate.After(lastReview.SubmittedAt) {
76+
return StateStale, true
77+
}
78+
}
79+
80+
// My review is current — skip
81+
return "", false
82+
}
83+
84+
func classifyAll(prs []PRNode, me string, includeSelf bool) []ClassifiedPR {
85+
var result []ClassifiedPR
86+
for _, pr := range prs {
87+
if !includeSelf && pr.Author.Login == me {
88+
continue
89+
}
90+
state, include := classify(pr, me)
91+
if !include {
92+
continue
93+
}
94+
result = append(result, ClassifiedPR{
95+
State: state,
96+
RepoName: pr.Repository.Name,
97+
RepoFullName: pr.Repository.NameWithOwner,
98+
Number: pr.Number,
99+
Title: pr.Title,
100+
Author: pr.Author.Login,
101+
URL: pr.URL,
102+
CreatedAt: pr.CreatedAt,
103+
})
104+
}
105+
106+
sort.Slice(result, func(i, j int) bool {
107+
pi, pj := statePriority[result[i].State], statePriority[result[j].State]
108+
if pi != pj {
109+
return pi < pj
110+
}
111+
return result[i].CreatedAt.After(result[j].CreatedAt)
112+
})
113+
114+
return result
115+
}

0 commit comments

Comments
 (0)