Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,15 @@ When a PR shows up in your inbox, `zen review <number>` opens that PR in a new t
- **Open PRs touching paths you watch** — for staying aware of areas you care about.

```bash
zen inbox # everything, filtered by configured authors
zen inbox --all # from all authors
zen inbox --path pkg/sts # PRs touching specific paths
zen inbox --repo other-repo # different repo
zen inbox # everything, filtered by configured authors
zen inbox --all # from all authors
zen inbox --path pkg/sts # PRs touching specific paths
zen inbox --repo other-repo # different repo
zen inbox --ignore-drafts # skip drafts on this run (or set ignore_drafts: true in config)
```

Drafts are shown by default. To skip drafts permanently, set `ignore_drafts: true` in your config — see [docs/configuration.md](docs/configuration.md). The `--ignore-drafts` flag overrides the config for a single run.

Example output:

```
Expand Down
39 changes: 23 additions & 16 deletions cmd/inbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ var inboxCmd = &cobra.Command{
}

var (
inboxRepo string
inboxAuthors string
inboxAll bool
inboxPathFilter string
inboxLimit int
inboxRepo string
inboxAuthors string
inboxAll bool
inboxPathFilter string
inboxLimit int
inboxIgnoreDrafts bool
)

func init() {
Expand All @@ -33,6 +34,7 @@ func init() {
inboxCmd.Flags().BoolVar(&inboxAll, "all", false, "Show from all authors")
inboxCmd.Flags().StringVarP(&inboxPathFilter, "path", "p", "", "List PRs touching files under DIR")
inboxCmd.Flags().IntVar(&inboxLimit, "limit", 100, "Max PRs to scan when using --path")
inboxCmd.Flags().BoolVar(&inboxIgnoreDrafts, "ignore-drafts", false, "Skip draft PRs (overrides config when set)")
rootCmd.AddCommand(inboxCmd)
}

Expand All @@ -46,7 +48,7 @@ type InboxPR struct {
MatchedCount int `json:"matched_count,omitempty"`
}

func runInbox(_ *cobra.Command, _ []string) error {
func runInbox(cmd *cobra.Command, _ []string) error {
repos := []string{inboxRepo}
if inboxRepo == "" {
repos = cfg.RepoNames()
Expand All @@ -60,6 +62,11 @@ func runInbox(_ *cobra.Command, _ []string) error {
authors = nil
}

ignoreDrafts := cfg.IgnoreDrafts
if cmd.Flags().Changed("ignore-drafts") {
ignoreDrafts = inboxIgnoreDrafts
}

// Cache current user once for all repos.
ctx := context.Background()
currentUser, _ := ghpkg.GetCurrentUser(ctx)
Expand All @@ -70,7 +77,7 @@ func runInbox(_ *cobra.Command, _ []string) error {

hasResults := false
for _, repo := range repos {
found, err := runInboxForRepo(repo, authors, currentUser)
found, err := runInboxForRepo(repo, authors, currentUser, ignoreDrafts)
if err != nil {
return err
}
Expand Down Expand Up @@ -100,14 +107,14 @@ func runInbox(_ *cobra.Command, _ []string) error {
return nil
}

func runInboxForRepo(repo string, authors []string, currentUser string) (bool, error) {
func runInboxForRepo(repo string, authors []string, currentUser string, ignoreDrafts bool) (bool, error) {
ctx := context.Background()
fullRepo := cfg.RepoFullName(repo)
localPRs := getLocalPRNumbers(repo)
hasResults := false

if inboxPathFilter != "" {
prs, err := fetchPRsByPath(ctx, fullRepo, inboxPathFilter, authors)
prs, err := fetchPRsByPath(ctx, fullRepo, inboxPathFilter, authors, ignoreDrafts)
if err != nil {
return false, err
}
Expand All @@ -124,11 +131,11 @@ func runInboxForRepo(repo string, authors []string, currentUser string) (bool, e

g, gctx := errgroup.WithContext(ctx)
g.Go(func() error {
reviews, reviewsErr = ghpkg.GetReviewRequests(gctx, fullRepo)
reviews, reviewsErr = ghpkg.GetReviewRequests(gctx, fullRepo, ignoreDrafts)
return nil
})
g.Go(func() error {
approved, approvedErr = ghpkg.GetApprovedUnmerged(gctx, fullRepo)
approved, approvedErr = ghpkg.GetApprovedUnmerged(gctx, fullRepo, ignoreDrafts)
return nil
})
_ = g.Wait()
Expand All @@ -150,7 +157,7 @@ func runInboxForRepo(repo string, authors []string, currentUser string) (bool, e
}

if len(cfg.WatchPaths) > 0 {
watched, others, err := fetchOpenPRs(ctx, fullRepo, currentUser)
watched, others, err := fetchOpenPRs(ctx, fullRepo, currentUser, ignoreDrafts)
if err == nil {
if len(watched) > 0 {
hasResults = true
Expand Down Expand Up @@ -216,10 +223,10 @@ func filterLocalPRs(prs []InboxPR, local map[int]bool) []InboxPR {
return pending
}

func fetchPRsByPath(ctx context.Context, fullRepo, pathPrefix string, authors []string) ([]InboxPR, error) {
func fetchPRsByPath(ctx context.Context, fullRepo, pathPrefix string, authors []string, ignoreDrafts bool) ([]InboxPR, error) {
pathPrefix = strings.TrimSuffix(pathPrefix, "/")

prs, err := ghpkg.ListOpenPRs(ctx, fullRepo, inboxLimit)
prs, err := ghpkg.ListOpenPRs(ctx, fullRepo, inboxLimit, ignoreDrafts)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -289,8 +296,8 @@ func fetchPRsByPath(ctx context.Context, fullRepo, pathPrefix string, authors []

// fetchOpenPRs splits recent open PRs into two groups: those touching watched
// paths and all others. The current user's PRs are excluded from both.
func fetchOpenPRs(ctx context.Context, fullRepo string, currentUser string) ([]InboxPR, []InboxPR, error) {
prs, err := ghpkg.ListOpenPRs(ctx, fullRepo, 30)
func fetchOpenPRs(ctx context.Context, fullRepo string, currentUser string, ignoreDrafts bool) ([]InboxPR, []InboxPR, error) {
prs, err := ghpkg.ListOpenPRs(ctx, fullRepo, 30, ignoreDrafts)
if err != nil {
return nil, nil, err
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ func saveState(seenPRs map[string]bool, prCount int) {
}

func pollOnce(ctx context.Context, seenPRs map[string]bool, queue workqueue.Interface, rec *reconciler.SetupReconciler) {
reviews, err := ghpkg.GetReviewRequests(ctx, "chainguard-dev/mono")
reviews, err := ghpkg.GetReviewRequests(ctx, "chainguard-dev/mono", cfg.IgnoreDrafts)
if err != nil {
fmt.Printf("[%s] Error fetching reviews: %v\n", time.Now().Format(time.RFC3339), err)
return
Expand Down
6 changes: 6 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ terminal: iterm # or "ghostty"
# If unset, falls back to `git config user.name` (spaces → hyphens), then no prefix.
branch_prefix: mgreau

# Skip draft PRs in `zen inbox`, watch notifications, and the MCP inbox tool.
# Defaults to false (drafts are shown). Set to true to skip drafts so you don't
# review something that isn't ready. Override on a single run with
# `--ignore-drafts=false`.
ignore_drafts: true

watch:
dispatch_interval: "10s" # How often to process queued work
cleanup_interval: "1h" # How often to scan for merged PRs
Expand Down
2 changes: 2 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@ claude mcp add --scope user zen -- zen mcp serve
| `zen_config_repos` | Configured repositories |
| `zen_review` | Create a worktree for a PR (auto-detects repo, injects context) |
| `zen_review_resume` | Get worktree path and sessions for an existing PR review |

> **Note:** `zen_inbox` uses the `ignore_drafts` setting from your config. Unlike the `zen inbox` CLI, there is no per-call override. Change the config (see [docs/configuration.md](configuration.md)) to toggle draft filtering for MCP callers.
17 changes: 9 additions & 8 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ import (

// Config holds the complete zen configuration.
type Config struct {
Repos map[string]RepoConfig `yaml:"repos"`
WatchPaths []string `yaml:"watch_paths"`
Authors []string `yaml:"authors"`
PollInterval string `yaml:"poll_interval"`
ClaudeBin string `yaml:"claude_bin"`
Terminal string `yaml:"terminal"` // "iterm" or "ghostty"
BranchPrefix string `yaml:"branch_prefix"`
Watch WatchConfig `yaml:"watch"`
Repos map[string]RepoConfig `yaml:"repos"`
WatchPaths []string `yaml:"watch_paths"`
Authors []string `yaml:"authors"`
PollInterval string `yaml:"poll_interval"`
ClaudeBin string `yaml:"claude_bin"`
Terminal string `yaml:"terminal"` // "iterm" or "ghostty"
BranchPrefix string `yaml:"branch_prefix"`
IgnoreDrafts bool `yaml:"ignore_drafts"`
Watch WatchConfig `yaml:"watch"`
}

// WatchConfig holds configuration for the watch daemon's workqueue behavior.
Expand Down
55 changes: 55 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,61 @@ func TestExpandPaths(t *testing.T) {
}
}

func TestLoadIgnoreDrafts(t *testing.T) {
tests := []struct {
name string
yaml string
want bool
}{
{
name: "omitted defaults to false",
yaml: `repos:
m:
full_name: o/m
base_path: /tmp/m
`,
want: false,
},
{
name: "explicit true",
yaml: `repos:
m:
full_name: o/m
base_path: /tmp/m
ignore_drafts: true
`,
want: true,
},
{
name: "explicit false",
yaml: `repos:
m:
full_name: o/m
base_path: /tmp/m
ignore_drafts: false
`,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("HOME", tmpDir)
zenDir := filepath.Join(tmpDir, ".zen")
os.MkdirAll(zenDir, 0o755)
os.WriteFile(filepath.Join(zenDir, "config.yaml"), []byte(tt.yaml), 0o644)

cfg, err := Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.IgnoreDrafts != tt.want {
t.Errorf("IgnoreDrafts = %v, want %v", cfg.IgnoreDrafts, tt.want)
}
})
}
}

func TestWatchConfigDefaults(t *testing.T) {
w := WatchConfig{}

Expand Down
67 changes: 47 additions & 20 deletions internal/github/queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,40 @@ func GetCurrentUser(ctx context.Context) (string, error) {
return strings.TrimSpace(string(out)), nil
}

// buildReviewRequestQueries returns the two GitHub search query strings used
// by GetReviewRequests: requested-reviews and re-review queries.
func buildReviewRequestQueries(repoFilter string, ignoreDrafts bool) (string, string) {
repoClause := ""
if repoFilter != "" {
repoClause = " repo:" + repoFilter
}
draftClause := ""
if ignoreDrafts {
draftClause = " draft:false"
}
q1 := fmt.Sprintf("is:pr is:open review-requested:@me%s%s", repoClause, draftClause)
q2 := fmt.Sprintf("is:pr is:open reviewed-by:@me review:required%s%s", repoClause, draftClause)
return q1, q2
}

// buildApprovedUnmergedQuery returns the GitHub search query string for
// GetApprovedUnmerged.
func buildApprovedUnmergedQuery(repoFilter string, ignoreDrafts bool) string {
repoClause := ""
if repoFilter != "" {
repoClause = " repo:" + repoFilter
}
draftClause := ""
if ignoreDrafts {
draftClause = " draft:false"
}
return fmt.Sprintf("is:pr is:open author:@me review:approved%s%s", repoClause, draftClause)
}

// GetReviewRequests fetches PRs where the user is a requested reviewer,
// including re-reviews. Uses GraphQL via `gh api graphql`.
func GetReviewRequests(ctx context.Context, repoFilter string) ([]ReviewRequest, error) {
// including re-reviews. Uses GraphQL via `gh api graphql`. When ignoreDrafts
// is true, draft PRs are filtered out at the GitHub search layer.
func GetReviewRequests(ctx context.Context, repoFilter string, ignoreDrafts bool) ([]ReviewRequest, error) {
ctx, cancel := withTimeout(ctx)
defer cancel()
query := `query($q1: String!, $q2: String!) {
Expand Down Expand Up @@ -104,13 +135,7 @@ func GetReviewRequests(ctx context.Context, repoFilter string) ([]ReviewRequest,
}
}`

repoClause := ""
if repoFilter != "" {
repoClause = " repo:" + repoFilter
}

q1 := fmt.Sprintf("is:pr is:open review-requested:@me%s", repoClause)
q2 := fmt.Sprintf("is:pr is:open reviewed-by:@me review:required%s", repoClause)
q1, q2 := buildReviewRequestQueries(repoFilter, ignoreDrafts)

cmd := exec.CommandContext(ctx, "gh", "api", "graphql",
"-f", "query="+query,
Expand Down Expand Up @@ -157,7 +182,8 @@ func GetReviewRequests(ctx context.Context, repoFilter string) ([]ReviewRequest,
}

// GetApprovedUnmerged fetches the user's own PRs that are approved but not yet merged.
func GetApprovedUnmerged(ctx context.Context, repoFilter string) ([]ApprovedPR, error) {
// When ignoreDrafts is true, draft PRs are excluded at the GitHub search layer.
func GetApprovedUnmerged(ctx context.Context, repoFilter string, ignoreDrafts bool) ([]ApprovedPR, error) {
ctx, cancel := withTimeout(ctx)
defer cancel()
query := `query($q: String!) {
Expand All @@ -176,12 +202,7 @@ func GetApprovedUnmerged(ctx context.Context, repoFilter string) ([]ApprovedPR,
}
}`

repoClause := ""
if repoFilter != "" {
repoClause = " repo:" + repoFilter
}

q := fmt.Sprintf("is:pr is:open author:@me review:approved%s", repoClause)
q := buildApprovedUnmergedQuery(repoFilter, ignoreDrafts)

cmd := exec.CommandContext(ctx, "gh", "api", "graphql",
"-f", "query="+query,
Expand Down Expand Up @@ -215,16 +236,22 @@ func GetApprovedUnmerged(ctx context.Context, repoFilter string) ([]ApprovedPR,
return filtered, nil
}

// ListOpenPRs lists open PRs for a repository using `gh pr list`.
func ListOpenPRs(ctx context.Context, fullRepo string, limit int) ([]ReviewRequest, error) {
// ListOpenPRs lists open PRs for a repository using `gh pr list`. When
// ignoreDrafts is true, drafts are excluded via `--draft=false`.
func ListOpenPRs(ctx context.Context, fullRepo string, limit int, ignoreDrafts bool) ([]ReviewRequest, error) {
ctx, cancel := withTimeout(ctx)
defer cancel()
cmd := exec.CommandContext(ctx, "gh", "pr", "list",
args := []string{
"pr", "list",
"-R", fullRepo,
"--state", "open",
"--limit", fmt.Sprintf("%d", limit),
"--json", "number,title,author,createdAt,url",
)
}
if ignoreDrafts {
args = append(args, "--draft=false")
}
cmd := exec.CommandContext(ctx, "gh", args...)
out, err := cmd.Output()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
Expand Down
Loading
Loading