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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- `prw dashboard` (alias `prw top`): a live table of your PRs across every repository on GitHub and Bitbucket Cloud, refreshed every minute (`--interval`). It finds them itself (`--review` adds PRs to review, `--merged --since 24h` recently merged ones), groups them by task β€” the issue key in the branch or title, else the branch without cherry-pick decorations β€” and shows one column per target branch in promotion order (dev β†’ staging β†’ rc/release β†’ main/master; other targets share an `other` column). Cells show the PR (a clickable link) and its most pressing state: failed CI, conflict, changes requested, running CI, approvals, ready, merged. CI is only re-read when a PR's head changes, while it runs, or every 15 minutes, so dozens of Bitbucket PRs fit in its rate limit; the footer shows requests per hour. `--once`, `--json`; `group_by`, `issue_pattern`, `branch_order`, `bitbucket_workspaces` and `bitbucket_user` config keys.
- Bitbucket Cloud pull requests: `prw watch`, `run`, `wait`, `checks`, `list`, `broadcast` and the MCP tools accept `bitbucket.org/<workspace>/<repo>/pull-requests/<N>` URLs. CI comes from the commit's build statuses (Bitbucket Pipelines, Jenkins, ...), reviews from participants' approvals and change requests, the merge state from the PR's merge checks. Authenticate with an Atlassian API token (`bitbucket_email` + `bitbucket_token`, or `BITBUCKET_EMAIL` / `BITBUCKET_TOKEN`) or an access token alone; public repositories need none. To stay within Bitbucket's 1,000 requests/hour, `prw run` checks each Bitbucket PR at most once a minute, and rate limits pause only the service that hit them. Auto-watch, `--logs` and `prw flaky` are GitHub-only for now.

### Changed
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ The tool is deliberately minimal. It does one thing well: watch PRs and tell you

## Features

- **`prw dashboard`**: a live table of all your PRs across repositories (GitHub and Bitbucket), grouped by task, one column per target branch β€” see how far each change and its cherry-picks got
- **`prw checks`**: every check of a PR with its result, duration and link β€” and with `--logs`, the output of the step that failed
- **`prw flaky`**: find a repository's flaky jobs (failed, then passed on re-run of the same commit)
- **`prw wait`**: block until a PR's CI finishes and exit 0/1/2 β€” for scripts, pipelines and AI coding agents
Expand Down Expand Up @@ -225,6 +226,44 @@ Stop watching a PR:
prw unwatch https://github.com/owner/repo/pull/123
```

## All your PRs at a glance: `prw dashboard`

`prw dashboard` (alias `prw top`) is a live table of your pull requests across every repository on GitHub and Bitbucket Cloud, refreshed every minute β€” no need to watch them first, prw finds them.

```
$ prw dashboard --review --merged
prw dashboard Β· 9 open PR(s) Β· 14:02:11 Β· every 1m Β· Ctrl+C to quit

Your PRs (incl. merged in the last 1d) (11)
TASK REPO dev rc main|master UPDATED TITLE
PROJ-123 acme/billing-api #12 🟣 merged #15 ⏳ 3/5 βœ“1 β€” 12m Add spending limits
acme/web #7 🟣 merged #8 βœ… CI ok βœ“2 #9 ❌ CI failed 20m Add spending limits
PROJ-98 acme/accounts β€” β€” #31 β›” conflict 12d Fix login timeout

To review (2)
TASK REPO dev rc main|master UPDATED TITLE
PAY-77 acme/payments #44 πŸš€ ready β€” β€” 3h Retry webhooks

API requests in the last hour: GitHub 48/h Β· Bitbucket 214/h
```

- **Rows are tasks**: PRs whose source branch or title share an issue key (`PROJ-123` β€” Jira, Linear, YouTrack…) share a row, and so do branches that only differ by cherry-pick / backport decorations (`feature/login`, `cherry-pick/feature/login-rc`). A change and its cherry-picks show up together, across repositories.
- **Columns are target branches in promotion order**: `dev|develop` β†’ `staging|qa|uat` β†’ `rc|release|release/*` β†’ `main|master|prod`, merged by tier so different naming across repositories still lines up. Other targets (e.g. stacked PRs) go in one `other` column that names them.
- **Cells** show the PR number β€” a clickable link in most terminals β€” and its most pressing state: ❌ CI failed, β›” conflict, πŸ“ changes requested, ⏳ CI running (done/total), βœ… CI ok, πŸš€ ready to merge, 🟣 merged; `βœ“N` counts approvals.
- **Filters**: your PRs by default; `--review` adds PRs where your review is requested, `--merged` your recently merged ones (`--since 24h` by default, e.g. `--since 3d`). `--mine=false` hides yours.
- `--once` prints once (also when piped), `--json` prints the PRs with their task for scripts.

**Requests stay low**: a PR's CI is only read again when its head changes, while it's running, or every 15 minutes; Bitbucket's merge checks are reused for 30 minutes while a PR is unchanged; PRs to review on Bitbucket (which can only be searched per repository) are rescanned every 10 minutes across repositories active in the last 30 days. Dozens of Bitbucket PRs fit in its 1,000 requests/hour; the footer shows the count.

**Tuning** (all optional):

```bash
prw config set group_by branch # auto (default), issue, branch, title, none
prw config set issue_pattern '[0-9]+-' # what an issue key looks like in your branches
prw config set branch_order dev,qa,uat,main # your own promotion order
prw config set bitbucket_workspaces acme,acme-labs # default: all your workspaces
```

## Waiting for CI in scripts and agents: `prw wait`

`prw wait` blocks until a PR's CI finishes and exits with a code you can branch on. The PR does not need to be on the watch list.
Expand Down
1 change: 1 addition & 0 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ Legend β€” Impact: 1–5 Β· Effort: S (≀½ day), M (1–2 days), L (3+ days)
- βœ… #16 Flakiness detection β€” done as `prw flaky` + MCP `get_flaky_checks` (failed-then-passed across attempts of the same run; verified live: grafana/grafana 4 flaky jobs in 300 runs, hashicorp/terraform "Unit Tests"; parallel fetching cut grafana from 24s to 9s). The "status history" half was dropped: low value for its cost.
- πŸš€ v0.4.0 released with everything above (PRs #6–#22).
- πŸ†• Bitbucket Cloud support (requested by the owner, who uses Bitbucket at work). Phase 1 done: PR identity carries the host; a Bitbucket client maps PRs, build statuses (Pipelines, Jenkins), participants and merge checks onto the existing types, so run / wait / checks / list / broadcast / MCP work unchanged; per-host rate-limit pauses; β‰₯60s between checks of a Bitbucket PR (1,000 req/h limit). Verified live on public atlassian/atlassian-connect-express PRs. Next: auto-watch (`--mine` / `--review-requested`) on Bitbucket, Pipelines step logs for `--logs`, then GitHub Enterprise (#2) on the same host abstraction.
- βœ… `prw dashboard` (owner's use case: ~25 repos Γ— dev/rc/master cherry-picks at work) β€” live table grouped by task across GitHub + Bitbucket, smart CI re-reads. Bitbucket discovery endpoints verified live anonymously (workspace user PRs, merged since, recent repos, reviewer filter); `/user` and `/user/workspaces` still need a check with the owner's token. Verified live on the owner's GitHub PRs.
- Next: #17 packaging (Homebrew/Scoop) + keychain token storage, #8 `gh` extension publishing β€” both need owner decisions

## Planned
Expand Down
316 changes: 316 additions & 0 deletions cmd/prw/dashboard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,316 @@
package main

import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/signal"
"strings"
"syscall"
"time"

"github.com/spf13/cobra"

"github.com/devblac/prw/internal/config"
"github.com/devblac/prw/internal/dashboard"
)

var (
dashMine bool
dashReview bool
dashMerged bool
dashSince string
// mergedWindow is the --since window, for the section title.
mergedWindow time.Duration
dashInterval time.Duration
dashOnce bool
dashJSON bool
dashGroupBy string
dashWidth int
dashNoLinks bool
)

func init() {
rootCmd.AddCommand(dashboardCmd)
f := dashboardCmd.Flags()
f.BoolVar(&dashMine, "mine", true, "your open PRs (--mine=false to hide them)")
f.BoolVar(&dashReview, "review", false, "PRs where your review is requested")
f.BoolVar(&dashMerged, "merged", false, "also your recently merged PRs (see --since)")
f.StringVar(&dashSince, "since", "24h", "with --merged, how far back: e.g. 24h, 3d")
f.DurationVar(&dashInterval, "interval", time.Minute, "how often to refresh")
f.BoolVar(&dashOnce, "once", false, "print once and exit")
f.BoolVar(&dashJSON, "json", false, "print the PRs as JSON once and exit")
f.StringVar(&dashGroupBy, "group-by", "", "group PRs into tasks by: auto, issue, branch, title or none (default: config group_by, or auto)")
f.IntVar(&dashWidth, "width", 0, "table width (default: the terminal's)")
f.BoolVar(&dashNoLinks, "no-links", false, "don't make PR numbers clickable links")
}

var dashboardCmd = &cobra.Command{
Use: "dashboard",
Aliases: []string{"top"},
Short: "A live table of your PRs across repositories, grouped by task",
Long: `Show your pull requests across every repository on GitHub and Bitbucket Cloud as a
live table that refreshes every minute, like top for PRs. You don't need to watch
them first: prw finds them.

Rows are tasks: PRs whose source branch or title share an issue key (PROJ-123), or
whose branches only differ by cherry-pick / backport decorations, share a row, so a
change and its cherry-picks to other branches show up together. Columns are target
branches in promotion order (dev, staging, rc/release, main/master), so you can see
at a glance how far each task got.

Each cell shows the PR number (a clickable link in most terminals) and its most
pressing state: ❌ CI failed, β›” conflict, πŸ“ changes requested, ⏳ CI running
(done/total), βœ… CI ok, πŸš€ ready to merge, 🟣 merged; βœ“N counts approvals.

Requests are kept low: a PR's CI is only read again when its head changes, while it
is running, or every 15 minutes. That fits dozens of Bitbucket PRs in its 1,000
requests per hour; the footer shows how many were used.

Tune grouping with 'prw config set group_by' / 'issue_pattern' and the column order
with 'prw config set branch_order dev,qa,main'.`,
Example: ` prw dashboard
prw dashboard --review --merged
prw dashboard --merged --since 3d --group-by branch
prw dashboard --once --json`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
filter := dashboard.Filter{Mine: dashMine, Review: dashReview}
if dashMerged {
window, err := parseWindow(dashSince)
if err != nil {
return err
}
filter.MergedSince = time.Now().Add(-window)
mergedWindow = window
}
if !filter.Mine && !filter.Review && !dashMerged {
return fmt.Errorf("nothing to show: --mine=false needs --review or --merged")
}
groupBy := dashGroupBy
if groupBy == "" {
groupBy = cfg.GroupBy
}
grouper, err := dashboard.NewGrouper(groupBy, cfg.IssuePattern)
if err != nil {
return err
}
sources, err := dashboardSources(cfg)
if err != nil {
return err
}
if dashInterval < 10*time.Second {
return fmt.Errorf("--interval must be at least 10s")
}

collector := dashboard.NewCollector(sources...)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

if dashJSON {
items, warnings := collector.Refresh(ctx, filter)
return printDashboardJSON(os.Stdout, items, warnings, grouper)
}

tty := isTerminal(os.Stdout)
live := !dashOnce && tty
if live {
enableTerminalEscapes()
}
view := dashboardView{grouper: grouper, order: cfg.BranchOrder, filter: filter, links: tty && !dashNoLinks}
for {
items, warnings := collector.Refresh(ctx, filter)
Comment on lines +127 to +128

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Advance the merged cutoff on each live refresh

When the dashboard runs live with --merged, MergedSince is calculated only at startup and the same filter is reused for every refresh. PRs therefore never age out of the selected window: a dashboard left running for several days still includes merges from its first day while labeling them as merged in the last 24 hours. Recompute the cutoff from the selected duration before each refresh and update the view's filter accordingly.

Useful? React with πŸ‘Β / πŸ‘Ž.

if ctx.Err() != nil {
return nil
}
var b strings.Builder
view.render(&b, items, warnings, collector.Requests(), time.Now(), live)
if live {
fmt.Print("\x1b[H\x1b[2J") // clear the screen and redraw in place
}
fmt.Print(b.String())
if !live {
return nil
}
select {
case <-ctx.Done():
fmt.Println()
return nil
case <-time.After(dashInterval):
}
}
},
}

// parseWindow accepts Go durations (24h, 90m) and days (3d).
func parseWindow(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if strings.HasSuffix(s, "d") {
var days int
if _, err := fmt.Sscanf(s, "%dd", &days); err == nil && days > 0 {
return time.Duration(days) * 24 * time.Hour, nil
}
}
d, err := time.ParseDuration(s)
if err != nil || d <= 0 {
return 0, fmt.Errorf("invalid --since %q (use e.g. 24h or 3d)", s)
}
return d, nil
}

// dashboardSources builds a source per configured account: GitHub with a token,
// Bitbucket with a token (or a bitbucket_user, for public repositories).
func dashboardSources(cfg *config.Config) ([]dashboard.Source, error) {
var sources []dashboard.Source
if token := cfg.GetToken(); token != "" {
sources = append(sources, &dashboard.GitHubSource{API: newGitHubClient(token)})
}
if auth, _ := cfg.ResolveBitbucketAuth(); auth.Token != "" || cfg.BitbucketUser != "" {
sources = append(sources, &dashboard.BitbucketSource{
API: newBitbucketClient(auth.Email, auth.Token),
User: cfg.BitbucketUser,
Workspaces: cfg.BitbucketWorkspaces,
})
}
if len(sources) == 0 {
return nil, fmt.Errorf("no account configured: log in with 'gh auth login' (GitHub) and/or set 'prw config set bitbucket_token' (Bitbucket)")
}
return sources, nil
}

type dashboardView struct {
grouper *dashboard.Grouper
order []string
filter dashboard.Filter
links bool
}

func (v dashboardView) render(w io.Writer, items []dashboard.Item, warnings, requests []string, now time.Time, live bool) {
var mine, review, targets []string
var mineItems, reviewItems []dashboard.Item
for _, it := range items {
targets = append(targets, it.Target)
if it.Role == dashboard.RoleReview {
reviewItems = append(reviewItems, it)
} else {
mineItems = append(mineItems, it)
}
}
_ = mine
_ = review
cols := dashboard.Columns(targets, v.order)

open := 0
for _, it := range items {
if it.State == dashboard.StateOpen {
open++
}
}
fmt.Fprintf(w, "prw dashboard Β· %d open PR(s) Β· %s", open, now.Format("15:04:05"))
if live {
fmt.Fprintf(w, " Β· every %s Β· Ctrl+C to quit", shortDuration(dashInterval))
}
fmt.Fprint(w, "\n\n")

var sections []dashboard.Section
if v.filter.Mine || !v.filter.MergedSince.IsZero() {
title := "Your PRs"
if !v.filter.MergedSince.IsZero() {
title += " (incl. merged in the last " + shortDuration(mergedWindow) + ")"
}
sections = append(sections, dashboard.Section{Title: title, Rows: v.grouper.Rows(mineItems)})
}
if v.filter.Review {
sections = append(sections, dashboard.Section{Title: "To review", Rows: v.grouper.Rows(reviewItems)})
}
width := dashWidth
if width <= 0 {
width = terminalWidth()
}
dashboard.Render(w, sections, cols, dashboard.RenderOptions{Now: now, Width: width, Links: v.links})

fmt.Fprintf(w, "\nAPI requests in the last hour: %s\n", strings.Join(requests, " Β· "))
const maxWarnings = 5
for i, warn := range warnings {
if i == maxWarnings {
fmt.Fprintf(w, "⚠️ … and %d more\n", len(warnings)-maxWarnings)
break
}
fmt.Fprintf(w, "⚠️ %s\n", warn)
}
}

// shortDuration formats d without zero parts: "3d", "1h30m", "1m", "30s".
func shortDuration(d time.Duration) string {
d = d.Round(time.Second)
if d >= 24*time.Hour && d%(24*time.Hour) == 0 {
return fmt.Sprintf("%dd", int(d.Hours()/24))
}
var b strings.Builder
for _, unit := range []struct {
size time.Duration
name string
}{{time.Hour, "h"}, {time.Minute, "m"}, {time.Second, "s"}} {
if n := d / unit.size; n > 0 {
fmt.Fprintf(&b, "%d%s", n, unit.name)
d -= n * unit.size
}
}
if b.Len() == 0 {
return "0s"
}
return b.String()
}

func printDashboardJSON(w io.Writer, items []dashboard.Item, warnings []string, g *dashboard.Grouper) error {
type jsonItem struct {
Task string `json:"task"`
dashboard.Item
}
out := struct {
Items []jsonItem `json:"items"`
Warnings []string `json:"warnings,omitempty"`
}{Items: []jsonItem{}, Warnings: warnings}
for _, it := range items {
out.Items = append(out.Items, jsonItem{Task: g.Task(it), Item: it})
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(out)
}

// isTerminal reports whether f is an interactive terminal (not a pipe or file).
func isTerminal(f *os.File) bool {
info, err := f.Stat()
return err == nil && info.Mode()&os.ModeCharDevice != 0
}

// terminalWidth is the width of the terminal: COLUMNS, then the console itself,
// then 140.
func terminalWidth() int {
var n int
if _, err := fmt.Sscan(os.Getenv("COLUMNS"), &n); err == nil && n > 0 {
return n
}
if n := consoleWidth(); n > 0 {
return n
}
return 140
}

// splitList parses a comma-separated config value.
func splitList(s string) []string {
var out []string
for _, part := range strings.Split(s, ",") {
if p := strings.TrimSpace(part); p != "" {
out = append(out, p)
}
}
return out
}
Loading
Loading