From 9296510518930c5a3d7609b274f4d3c000e7da0c Mon Sep 17 00:00:00 2001 From: Julian Blacher Date: Thu, 29 Oct 2026 23:30:34 -0300 Subject: [PATCH] feat: `prw dashboard`, a live table of your PRs grouped by task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prw dashboard` (alias `prw top`) shows your pull requests across every repository on GitHub and Bitbucket Cloud as a table refreshed every minute, like top for PRs. It finds them itself: GitHub's search (yours, to review, recently merged), and on Bitbucket one request per workspace for yours and a reviewer query per recently active repository (every 10 minutes) for the ones to review. - internal/dashboard: rows are tasks — the issue key in the branch or title (PROJ-123, overridable with issue_pattern), else the source branch without cherry-pick / backport decorations — so a change and its cherry-picks share a row, across repositories. Columns are target branches in promotion order (dev → staging/qa → rc/release → main/master), merged by tier so naming differences line up; stacked PRs' targets share an "other" column. Cells show the PR (an OSC 8 hyperlink) and its most pressing state. group_by and branch_order override the heuristics. - Request budget: a PR's CI is only re-read when its head changes, while it runs, or every 15 minutes; Bitbucket merge checks are reused for 30 minutes while the PR is unchanged; enrichment runs 4 at a time. Clients count their requests and the footer shows them per hour. - Bitbucket client: CurrentUser, Workspaces, UserPullRequests (per workspace, partial responses incl. participants), RecentRepos, ReviewPullRequests, MergeStateWithin. PRs carry source/target branches on both services. - Flags: --review, --merged --since 24h, --mine=false, --interval, --once, --json, --group-by, --width, --no-links. Config: group_by, issue_pattern, branch_order, bitbucket_workspaces, bitbucket_user. - Terminal width and ANSI escapes via the standard library only (Windows console API, Unix ioctl). Verified live: the owner's GitHub PRs (open, merged, stacked, a fresh CI failure) and public Bitbucket PRs of an Atlassian user across a workspace. The Bitbucket /user and /user/workspaces calls need a real token to verify. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 39 ++++ TASKS.md | 1 + cmd/prw/dashboard.go | 316 ++++++++++++++++++++++++++++ cmd/prw/dashboard_test.go | 139 +++++++++++++ cmd/prw/main.go | 32 +++ cmd/prw/term_other.go | 7 + cmd/prw/term_unix.go | 22 ++ cmd/prw/term_windows.go | 49 +++++ internal/bitbucket/client.go | 16 ++ internal/bitbucket/discover.go | 188 +++++++++++++++++ internal/config/config.go | 19 +- internal/dashboard/branches.go | 136 ++++++++++++ internal/dashboard/collect.go | 167 +++++++++++++++ internal/dashboard/collect_test.go | 126 +++++++++++ internal/dashboard/group.go | 158 ++++++++++++++ internal/dashboard/group_test.go | 148 +++++++++++++ internal/dashboard/model.go | 66 ++++++ internal/dashboard/render.go | 234 +++++++++++++++++++++ internal/dashboard/render_test.go | 142 +++++++++++++ internal/dashboard/sources.go | 322 +++++++++++++++++++++++++++++ internal/dashboard/sources_test.go | 232 +++++++++++++++++++++ internal/github/client.go | 34 +++ 23 files changed, 2590 insertions(+), 4 deletions(-) create mode 100644 cmd/prw/dashboard.go create mode 100644 cmd/prw/dashboard_test.go create mode 100644 cmd/prw/term_other.go create mode 100644 cmd/prw/term_unix.go create mode 100644 cmd/prw/term_windows.go create mode 100644 internal/bitbucket/discover.go create mode 100644 internal/dashboard/branches.go create mode 100644 internal/dashboard/collect.go create mode 100644 internal/dashboard/collect_test.go create mode 100644 internal/dashboard/group.go create mode 100644 internal/dashboard/group_test.go create mode 100644 internal/dashboard/model.go create mode 100644 internal/dashboard/render.go create mode 100644 internal/dashboard/render_test.go create mode 100644 internal/dashboard/sources.go create mode 100644 internal/dashboard/sources_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f6d344..fe83e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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///pull-requests/` 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 diff --git a/README.md b/README.md index 53a1e5f..8106131 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/TASKS.md b/TASKS.md index f21a115..db63efa 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 diff --git a/cmd/prw/dashboard.go b/cmd/prw/dashboard.go new file mode 100644 index 0000000..9bf3aac --- /dev/null +++ b/cmd/prw/dashboard.go @@ -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) + 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 +} diff --git a/cmd/prw/dashboard_test.go b/cmd/prw/dashboard_test.go new file mode 100644 index 0000000..7bfa474 --- /dev/null +++ b/cmd/prw/dashboard_test.go @@ -0,0 +1,139 @@ +package main + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/devblac/prw/internal/config" + "github.com/devblac/prw/internal/dashboard" + "github.com/devblac/prw/internal/forge" +) + +func TestParseWindow(t *testing.T) { + for in, want := range map[string]time.Duration{"24h": 24 * time.Hour, "90m": 90 * time.Minute, "3d": 72 * time.Hour, " 1d ": 24 * time.Hour} { + if got, err := parseWindow(in); err != nil || got != want { + t.Errorf("parseWindow(%q) = %s, %v", in, got, err) + } + } + for _, bad := range []string{"", "0d", "-1h", "soon", "3w"} { + if _, err := parseWindow(bad); err == nil { + t.Errorf("parseWindow(%q) should fail", bad) + } + } +} + +func TestDashboardSources(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "") + t.Setenv("GH_TOKEN", "") + t.Setenv("BITBUCKET_TOKEN", "") + old := config.GHAuthToken + config.GHAuthToken = func() (string, error) { return "", errNoGH } + t.Cleanup(func() { config.GHAuthToken = old }) + + if _, err := dashboardSources(&config.Config{}); err == nil || !strings.Contains(err.Error(), "no account configured") { + t.Errorf("err = %v", err) + } + names := func(cfg *config.Config) string { + srcs, err := dashboardSources(cfg) + if err != nil { + t.Fatal(err) + } + var n []string + for _, s := range srcs { + n = append(n, s.Name()) + } + return strings.Join(n, "+") + } + if got := names(&config.Config{GitHubToken: "t"}); got != "GitHub" { + t.Errorf("GitHub only: %s", got) + } + if got := names(&config.Config{BitbucketToken: "t"}); got != "Bitbucket" { + t.Errorf("Bitbucket token: %s", got) + } + if got := names(&config.Config{GitHubToken: "t", BitbucketUser: "{u}"}); got != "GitHub+Bitbucket" { + t.Errorf("both (public Bitbucket user): %s", got) + } +} + +var errNoGH = &json.UnsupportedValueError{Str: "no gh"} + +func dashItem(host, repo string, number int, source, target, state, role, ci string) dashboard.Item { + owner, name, _ := strings.Cut(repo, "/") + ref := forge.Ref{Host: host, Owner: owner, Repo: name, Number: number} + return dashboard.Item{Ref: ref, URL: ref.URL(), Host: host, Repo: repo, Number: number, Title: "T " + source, + Source: source, Target: target, State: state, Role: role, CI: ci, UpdatedAt: time.Now().Add(-time.Hour)} +} + +func TestDashboardView(t *testing.T) { + g, _ := dashboard.NewGrouper("", "") + items := []dashboard.Item{ + dashItem(forge.Bitbucket, "sec/api", 1, "SEC-1/a", "dev", dashboard.StateMerged, dashboard.RoleMine, ""), + dashItem(forge.Bitbucket, "sec/api", 2, "cherry-pick/SEC-1-rc", "rc", dashboard.StateOpen, dashboard.RoleMine, "pending"), + dashItem(forge.GitHub, "me/tool", 3, "fix/x", "main", dashboard.StateOpen, dashboard.RoleMine, "failure"), + dashItem(forge.Bitbucket, "sec/web", 4, "SEC-9/z", "dev", dashboard.StateOpen, dashboard.RoleReview, "success"), + } + v := dashboardView{grouper: g, filter: dashboard.Filter{Mine: true, Review: true, MergedSince: time.Now().Add(-24 * time.Hour)}} + oldWindow, oldWidth := mergedWindow, dashWidth + mergedWindow, dashWidth = 24*time.Hour, 160 + t.Cleanup(func() { mergedWindow, dashWidth = oldWindow, oldWidth }) + + var buf bytes.Buffer + v.render(&buf, items, []string{"Bitbucket: sec: 429"}, []string{"GitHub 5/h", "Bitbucket 12/h"}, time.Now(), true) + out := buf.String() + for _, want := range []string{ + "prw dashboard · 3 open PR(s)", "every 1m", "Ctrl+C", + "Your PRs (incl. merged in the last 1d) (3)", + "dev", "rc", "main", + "SEC-1", "#1 🟣 merged", "#2 ⏳ CI", + "#3 ❌ CI failed", + "To review (1)", "#4 ✅ CI ok", + "API requests in the last hour: GitHub 5/h · Bitbucket 12/h", + "⚠️ Bitbucket: sec: 429", + } { + if !strings.Contains(out, want) { + t.Errorf("output lacks %q:\n%s", want, out) + } + } + // The task and its cherry-pick share a row. + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, "#1 🟣 merged") && !strings.Contains(line, "#2 ⏳") { + t.Errorf("SEC-1 and its cherry-pick must share a row: %q", line) + } + } +} + +func TestPrintDashboardJSON(t *testing.T) { + g, _ := dashboard.NewGrouper("", "") + var buf bytes.Buffer + items := []dashboard.Item{dashItem(forge.Bitbucket, "sec/api", 2, "cherry-pick/SEC-1-rc", "rc", dashboard.StateOpen, dashboard.RoleMine, "success")} + if err := printDashboardJSON(&buf, items, nil, g); err != nil { + t.Fatal(err) + } + var out struct { + Items []map[string]interface{} `json:"items"` + } + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("%v\n%s", err, buf.String()) + } + it := out.Items[0] + if it["task"] != "SEC-1" || it["url"] != "https://bitbucket.org/sec/api/pull-requests/2" || it["target_branch"] != "rc" || it["ci"] != "success" { + t.Errorf("item = %v", it) + } +} + +func TestShortDuration(t *testing.T) { + for d, want := range map[time.Duration]string{24 * time.Hour: "1d", 72 * time.Hour: "3d", time.Minute: "1m", 90 * time.Minute: "1h30m", 30 * time.Second: "30s"} { + if got := shortDuration(d); got != want { + t.Errorf("shortDuration(%s) = %q, want %q", d, got, want) + } + } +} + +func TestSplitList(t *testing.T) { + if got := strings.Join(splitList(" dev, qa ,,main "), "|"); got != "dev|qa|main" { + t.Errorf("splitList = %s", got) + } +} diff --git a/cmd/prw/main.go b/cmd/prw/main.go index 87b7984..954e215 100644 --- a/cmd/prw/main.go +++ b/cmd/prw/main.go @@ -16,6 +16,7 @@ import ( "github.com/spf13/cobra" "github.com/devblac/prw/internal/config" + "github.com/devblac/prw/internal/dashboard" "github.com/devblac/prw/internal/forge" "github.com/devblac/prw/internal/github" "github.com/devblac/prw/internal/notify" @@ -530,6 +531,11 @@ Supported keys: - github_token: GitHub personal access token - bitbucket_token: Bitbucket Cloud API token (with bitbucket_email), or an access token alone - bitbucket_email: the Atlassian account email that goes with an API token + - bitbucket_workspaces: workspaces 'prw dashboard' looks in, comma-separated (default: all of yours) + - bitbucket_user: whose Bitbucket PRs 'prw dashboard' shows, UUID or account id (default: the token's owner) + - group_by: how 'prw dashboard' groups PRs into tasks: auto, issue, branch, title or none (default: auto) + - issue_pattern: regexp for issue keys in branches and titles (default: [A-Z][A-Z0-9]{1,9}-[0-9]+) + - branch_order: target branches in promotion order, comma-separated, e.g. dev,qa,main - notification_filter: change, fail, or success - notification_native: enable native OS notifications (true/false) - pr_events: notify about reviews and merge conflicts / readiness, not just CI (true/false, default: true)`, @@ -575,6 +581,22 @@ func applyConfigSet(cfg *config.Config, key, value string) error { cfg.BitbucketToken = value case "bitbucket_email": cfg.BitbucketEmail = value + case "bitbucket_workspaces": + cfg.BitbucketWorkspaces = splitList(value) + case "bitbucket_user": + cfg.BitbucketUser = strings.TrimSpace(value) + case "group_by": + if !dashboard.ValidGroupBy(value) { + return fmt.Errorf("group_by must be one of: auto, issue, branch, title, none") + } + cfg.GroupBy = value + case "issue_pattern": + if _, err := dashboard.NewGrouper(dashboard.GroupAuto, value); err != nil { + return err + } + cfg.IssuePattern = value + case "branch_order": + cfg.BranchOrder = splitList(value) case "notification_filter": filter := config.NormalizeNotificationFilter(value) if !config.IsValidNotificationFilter(filter) { @@ -631,6 +653,16 @@ func applyConfigUnset(cfg *config.Config, key string) error { cfg.BitbucketToken = "" case "bitbucket_email": cfg.BitbucketEmail = "" + case "bitbucket_workspaces": + cfg.BitbucketWorkspaces = nil + case "bitbucket_user": + cfg.BitbucketUser = "" + case "group_by": + cfg.GroupBy = "" + case "issue_pattern": + cfg.IssuePattern = "" + case "branch_order": + cfg.BranchOrder = nil case "notification_filter": cfg.NotificationFilter = config.NotificationFilterChange case "notification_native": diff --git a/cmd/prw/term_other.go b/cmd/prw/term_other.go new file mode 100644 index 0000000..8998726 --- /dev/null +++ b/cmd/prw/term_other.go @@ -0,0 +1,7 @@ +//go:build !windows && !linux && !darwin + +package main + +func consoleWidth() int { return 0 } + +func enableTerminalEscapes() {} diff --git a/cmd/prw/term_unix.go b/cmd/prw/term_unix.go new file mode 100644 index 0000000..dc2de9b --- /dev/null +++ b/cmd/prw/term_unix.go @@ -0,0 +1,22 @@ +//go:build linux || darwin + +package main + +import ( + "os" + "syscall" + "unsafe" +) + +// consoleWidth is the width of the terminal on stdout, or 0. +func consoleWidth() int { + var ws struct{ Row, Col, X, Y uint16 } + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, os.Stdout.Fd(), uintptr(syscall.TIOCGWINSZ), uintptr(unsafe.Pointer(&ws))) + if errno != 0 { + return 0 + } + return int(ws.Col) +} + +// enableTerminalEscapes is a no-op: Unix terminals understand ANSI escapes. +func enableTerminalEscapes() {} diff --git a/cmd/prw/term_windows.go b/cmd/prw/term_windows.go new file mode 100644 index 0000000..56d344d --- /dev/null +++ b/cmd/prw/term_windows.go @@ -0,0 +1,49 @@ +//go:build windows + +package main + +import ( + "os" + "syscall" + "unsafe" +) + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procGetConsoleScreenBufferInfo = kernel32.NewProc("GetConsoleScreenBufferInfo") + procGetConsoleMode = kernel32.NewProc("GetConsoleMode") + procSetConsoleMode = kernel32.NewProc("SetConsoleMode") +) + +type consoleCoord struct{ X, Y int16 } + +type consoleRect struct{ Left, Top, Right, Bottom int16 } + +type consoleScreenBufferInfo struct { + Size consoleCoord + CursorPosition consoleCoord + Attributes uint16 + Window consoleRect + MaximumWindowSize consoleCoord +} + +// consoleWidth is the visible width of the Windows console, or 0. +func consoleWidth() int { + var info consoleScreenBufferInfo + r, _, _ := procGetConsoleScreenBufferInfo.Call(os.Stdout.Fd(), uintptr(unsafe.Pointer(&info))) + if r == 0 { + return 0 + } + return int(info.Window.Right-info.Window.Left) + 1 +} + +// enableTerminalEscapes turns on ANSI escape sequences (clearing the screen, +// hyperlinks) in consoles that need it; Windows Terminal already has them. +func enableTerminalEscapes() { + const enableVirtualTerminalProcessing = 0x0004 + var mode uint32 + if r, _, _ := procGetConsoleMode.Call(os.Stdout.Fd(), uintptr(unsafe.Pointer(&mode))); r == 0 { + return + } + procSetConsoleMode.Call(os.Stdout.Fd(), uintptr(mode|enableVirtualTerminalProcessing)) +} diff --git a/internal/bitbucket/client.go b/internal/bitbucket/client.go index 449c1c2..022985c 100644 --- a/internal/bitbucket/client.go +++ b/internal/bitbucket/client.go @@ -84,6 +84,9 @@ func NewClient(email, token string) *Client { // SetBaseURL points the client at another server (tests). func (c *Client) SetBaseURL(base string) { c.http.BaseURL = strings.TrimRight(base, "/") } +// RequestsInLastHour is how many requests the client sent in the last hour. +func (c *Client) RequestsInLastHour() int { return c.http.RequestsInLastHour() } + // RateLimit returns the quota reported by the most recent response, if any. func (c *Client) RateLimit() github.RateLimit { return c.http.RateLimit() } @@ -96,7 +99,18 @@ type pullRequest struct { Commit struct { Hash string `json:"hash"` } `json:"commit"` + Branch struct { + Name string `json:"name"` + } `json:"branch"` } `json:"source"` + Destination struct { + Branch struct { + Name string `json:"name"` + } `json:"branch"` + Repository struct { + FullName string `json:"full_name"` + } `json:"repository"` + } `json:"destination"` Participants []struct { User struct { UUID string `json:"uuid"` @@ -136,6 +150,8 @@ func (c *Client) GetPullRequest(ctx context.Context, workspace, repo string, num } pr := &github.PullRequest{Number: bb.ID, Title: bb.Title, UpdatedAt: bb.UpdatedOn} pr.Head.SHA = bb.Source.Commit.Hash + pr.Head.Ref = bb.Source.Branch.Name + pr.Base.Ref = bb.Destination.Branch.Name switch strings.ToUpper(bb.State) { case "OPEN": pr.State = "open" diff --git a/internal/bitbucket/discover.go b/internal/bitbucket/discover.go new file mode 100644 index 0000000..56716a0 --- /dev/null +++ b/internal/bitbucket/discover.go @@ -0,0 +1,188 @@ +package bitbucket + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + "time" +) + +// prListFields asks list endpoints for just what the dashboard shows, participants +// included, so one request covers every PR on a page. +const prListFields = "next,values.id,values.title,values.state,values.updated_on," + + "values.source.branch.name,values.source.commit.hash," + + "values.destination.branch.name,values.destination.repository.full_name," + + "values.participants.approved,values.participants.state,values.participants.user.uuid" + +const ( + listPageLen = 50 + maxListPages = 10 +) + +// ListedPR is a pull request as returned by Bitbucket's list endpoints. +type ListedPR struct { + Workspace string + Repo string + Number int + Title string + State string // OPEN, MERGED, DECLINED, SUPERSEDED + UpdatedOn time.Time + Source string // branch + Target string // branch + SHA string + // Approvals counts participants who approved; ChangesRequested is set when a + // participant requested changes. + Approvals int + ChangesRequested bool +} + +func toListed(pr pullRequest) ListedPR { + l := ListedPR{ + Number: pr.ID, Title: pr.Title, State: strings.ToUpper(pr.State), UpdatedOn: pr.UpdatedOn, + Source: pr.Source.Branch.Name, Target: pr.Destination.Branch.Name, SHA: pr.Source.Commit.Hash, + } + if ws, repo, ok := strings.Cut(pr.Destination.Repository.FullName, "/"); ok { + l.Workspace, l.Repo = ws, repo + } + for _, p := range pr.Participants { + switch { + case strings.EqualFold(p.State, "changes_requested"): + l.ChangesRequested = true + case p.Approved: + l.Approvals++ + } + } + return l +} + +// listPRs follows the pages of a pull request list endpoint. +func (c *Client) listPRs(ctx context.Context, path string) ([]ListedPR, error) { + var out []ListedPR + for page := 0; page < maxListPages && path != ""; page++ { + var resp struct { + Values []pullRequest `json:"values"` + Next string `json:"next"` + } + if err := c.http.GetJSON(ctx, path, &resp); err != nil { + return nil, err + } + for _, pr := range resp.Values { + out = append(out, toListed(pr)) + } + path = c.relative(resp.Next) + } + return out, nil +} + +// CurrentUser returns the authenticated user's UUID (e.g. "{a1b2...}"). +func (c *Client) CurrentUser(ctx context.Context) (string, error) { + var u struct { + UUID string `json:"uuid"` + } + if err := c.http.GetJSON(ctx, "/user?fields=uuid", &u); err != nil { + return "", err + } + if u.UUID == "" { + return "", errors.New("Bitbucket didn't say who the token belongs to") + } + return u.UUID, nil +} + +// Workspaces returns the slugs of the workspaces the authenticated user belongs to. +func (c *Client) Workspaces(ctx context.Context) ([]string, error) { + var lastErr error + // /user/workspaces replaced /user/permissions/workspaces; try both. + for _, path := range []string{"/user/workspaces?pagelen=100&fields=values.workspace.slug", "/user/permissions/workspaces?pagelen=100&fields=values.workspace.slug"} { + var resp struct { + Values []struct { + Workspace struct { + Slug string `json:"slug"` + } `json:"workspace"` + } `json:"values"` + } + if err := c.http.GetJSON(ctx, path, &resp); err != nil { + lastErr = err + continue + } + var slugs []string + for _, v := range resp.Values { + if v.Workspace.Slug != "" { + slugs = append(slugs, v.Workspace.Slug) + } + } + return slugs, nil + } + return nil, lastErr +} + +// UserPullRequests lists the pull requests authored by user (a UUID or account id) +// in workspace, across its repositories. state is OPEN or MERGED; a non-zero +// updatedSince keeps only PRs updated since then. +func (c *Client) UserPullRequests(ctx context.Context, workspace, user, state string, updatedSince time.Time) ([]ListedPR, error) { + q := url.Values{} + q.Set("state", state) + q.Set("pagelen", fmt.Sprint(listPageLen)) + q.Set("fields", prListFields) + if !updatedSince.IsZero() { + q.Set("q", fmt.Sprintf("updated_on > %s", updatedSince.UTC().Format(time.RFC3339))) + } + path := fmt.Sprintf("/workspaces/%s/pullrequests/%s?%s", url.PathEscape(workspace), url.PathEscape(user), q.Encode()) + return c.listPRs(ctx, path) +} + +// RecentRepos returns the slugs of the repositories in workspace updated since +// since, most recent first, at most max. +func (c *Client) RecentRepos(ctx context.Context, workspace string, since time.Time, max int) ([]string, error) { + q := url.Values{} + q.Set("sort", "-updated_on") + q.Set("pagelen", "100") + q.Set("fields", "next,values.slug") + q.Set("q", fmt.Sprintf("updated_on > %s", since.UTC().Format(time.RFC3339))) + path := "/repositories/" + url.PathEscape(workspace) + "?" + q.Encode() + var slugs []string + for page := 0; page < maxListPages && path != "" && len(slugs) < max; page++ { + var resp struct { + Values []struct { + Slug string `json:"slug"` + } `json:"values"` + Next string `json:"next"` + } + if err := c.http.GetJSON(ctx, path, &resp); err != nil { + return nil, err + } + for _, v := range resp.Values { + slugs = append(slugs, v.Slug) + } + path = c.relative(resp.Next) + } + if len(slugs) > max { + slugs = slugs[:max] + } + return slugs, nil +} + +// ReviewPullRequests lists the open pull requests of a repository where user is a +// reviewer. Bitbucket can only filter by reviewer per repository. +func (c *Client) ReviewPullRequests(ctx context.Context, workspace, repo, user string) ([]ListedPR, error) { + q := url.Values{} + q.Set("q", fmt.Sprintf(`state = "OPEN" AND reviewers.uuid = %q`, user)) + q.Set("pagelen", fmt.Sprint(listPageLen)) + q.Set("fields", prListFields) + return c.listPRs(ctx, repoPath(workspace, repo)+"/pullrequests?"+q.Encode()) +} + +// MergeStateWithin returns the merge state of an open PR like GetPullRequest does, +// reusing a previous answer for up to ttl while the PR's updated_on is unchanged. +// The dashboard uses a long ttl: it shows many PRs within the same request budget. +func (c *Client) MergeStateWithin(ctx context.Context, workspace, repo string, number int, updatedOn time.Time, ttl time.Duration) string { + key := prKey(workspace, repo, number) + c.mu.Lock() + cached, ok := c.mergeability[key] + c.mu.Unlock() + if ok && cached.updatedOn.Equal(updatedOn) && c.now().Sub(cached.fetchedAt) < ttl { + return cached.state + } + return c.mergeState(ctx, workspace, repo, number, updatedOn) +} diff --git a/internal/config/config.go b/internal/config/config.go index c7fa999..8e63717 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,10 +30,21 @@ type Config struct { // BitbucketToken and BitbucketEmail authenticate against Bitbucket Cloud: an // Atlassian API token with the account's email (Basic auth), or a workspace / // project / repository access token alone (Bearer). See ResolveBitbucketAuth. - BitbucketToken string `json:"bitbucket_token,omitempty"` - BitbucketEmail string `json:"bitbucket_email,omitempty"` - NotificationFilter string `json:"notification_filter,omitempty"` - NotificationNative bool `json:"notification_native,omitempty"` + BitbucketToken string `json:"bitbucket_token,omitempty"` + BitbucketEmail string `json:"bitbucket_email,omitempty"` + // BitbucketWorkspaces limits `prw dashboard` to these workspaces (all of the + // token owner's when empty); BitbucketUser is whose PRs it shows (the token + // owner when empty), as a UUID or account id. + BitbucketWorkspaces []string `json:"bitbucket_workspaces,omitempty"` + BitbucketUser string `json:"bitbucket_user,omitempty"` + + // Dashboard settings: GroupBy (auto, issue, branch, title, none), IssuePattern + // (a regexp for issue keys) and BranchOrder (target branches in promotion order). + GroupBy string `json:"group_by,omitempty"` + IssuePattern string `json:"issue_pattern,omitempty"` + BranchOrder []string `json:"branch_order,omitempty"` + NotificationFilter string `json:"notification_filter,omitempty"` + NotificationNative bool `json:"notification_native,omitempty"` // DisablePREvents turns off review (approved / changes requested) and merge // (conflict / ready to merge) notifications; `pr_events` in `prw config`. DisablePREvents bool `json:"disable_pr_events,omitempty"` diff --git a/internal/dashboard/branches.go b/internal/dashboard/branches.go new file mode 100644 index 0000000..498fe55 --- /dev/null +++ b/internal/dashboard/branches.go @@ -0,0 +1,136 @@ +package dashboard + +import ( + "sort" + "strings" +) + +// branchTiers ranks well-known target branches in promotion order. Branches in the +// same tier share a column. +var branchTiers = [][]string{ + {"dev", "develop", "development", "devel"}, + {"staging", "stage", "qa", "test", "testing", "uat", "preprod", "pre-prod"}, + {"rc", "release", "release-candidate", "pre-release", "prerelease"}, + {"main", "master", "trunk", "prod", "production", "live", "stable"}, +} + +// tierOf returns the promotion tier of a branch, or -1. "release/1.2" and +// "release-1.2" count as release. +func tierOf(branch string) int { + b := strings.ToLower(branch) + for i, tier := range branchTiers { + for _, name := range tier { + if b == name || strings.HasPrefix(b, name+"/") || strings.HasPrefix(b, name+"-") { + return i + } + } + } + return -1 +} + +// Column is one target-branch column of the dashboard. +type Column struct { + Title string // e.g. "main|master" + Branches []string // the target branches shown in it + // Other holds every remaining target branch (e.g. the feature branches stacked + // PRs point at), so they don't each add a column; its cells name the target. + Other bool +} + +// OtherTitle is the title of the column for target branches outside the order. +const OtherTitle = "other" + +// Columns derives the dashboard's columns from the target branches in use. +// +// With order (branch_order), each listed branch is a column in that order. Otherwise +// branches of the same promotion tier (dev, staging, rc/release, main/master) share +// a column, tiers in promotion order. Any other branch goes in one last "other" +// column. +func Columns(targets []string, order []string) []Column { + seen := map[string]bool{} + var branches []string + for _, t := range targets { + if t != "" && !seen[t] { + seen[t] = true + branches = append(branches, t) + } + } + sort.Strings(branches) + + if len(order) > 0 { + rank := map[string]int{} + for i, b := range order { + rank[strings.ToLower(strings.TrimSpace(b))] = i + 1 + } + sort.SliceStable(branches, func(i, j int) bool { + return rank[strings.ToLower(branches[i])] < rank[strings.ToLower(branches[j])] + }) + var cols []Column + var others []string + for _, b := range branches { + if rank[strings.ToLower(b)] == 0 { + others = append(others, b) + continue + } + cols = append(cols, Column{Title: b, Branches: []string{b}}) + } + return withOther(cols, others) + } + + tiers := make([][]string, len(branchTiers)) + var others []string + for _, b := range branches { + if t := tierOf(b); t >= 0 { + tiers[t] = append(tiers[t], b) + } else { + others = append(others, b) + } + } + var cols []Column + for _, bs := range tiers { + if len(bs) > 0 { + cols = append(cols, Column{Title: tierTitle(bs), Branches: bs}) + } + } + return withOther(cols, others) +} + +func withOther(cols []Column, others []string) []Column { + if len(others) > 0 { + cols = append(cols, Column{Title: OtherTitle, Branches: others, Other: true}) + } + return cols +} + +// tierTitle names a shared column: "main|master", or "release/*" for versioned +// release branches. +func tierTitle(branches []string) string { + var names []string + seen := map[string]bool{} + for _, b := range branches { + name := b + if i := strings.IndexAny(b, "/"); i > 0 { + name = b[:i] + "/*" + } + if !seen[name] { + seen[name] = true + names = append(names, name) + } + } + if len(names) > 3 { + names = append(names[:3], "…") + } + return strings.Join(names, "|") +} + +// ColumnOf returns the index of the column showing target, or -1. +func ColumnOf(cols []Column, target string) int { + for i, c := range cols { + for _, b := range c.Branches { + if b == target { + return i + } + } + } + return -1 +} diff --git a/internal/dashboard/collect.go b/internal/dashboard/collect.go new file mode 100644 index 0000000..36c80da --- /dev/null +++ b/internal/dashboard/collect.go @@ -0,0 +1,167 @@ +package dashboard + +import ( + "context" + "fmt" + "sort" + "sync" + "time" +) + +// Filter selects which PRs the dashboard shows. +type Filter struct { + Mine bool + Review bool + // MergedSince, if not zero, adds your PRs merged since then. + MergedSince time.Time +} + +// Source is a code host the dashboard reads PRs from. +type Source interface { + Name() string + // Discover lists the PRs matching f, with whatever the list provides (for + // Bitbucket, approvals too). Warnings are non-fatal problems. + Discover(ctx context.Context, f Filter) (items []Item, warnings []string, err error) + // Enrich fills in an open item's CI (only if needCI), reviews and merge state. + Enrich(ctx context.Context, it *Item, needCI bool) error + // RequestsInLastHour is how many API requests the source made in the last hour. + RequestsInLastHour() int +} + +// DefaultCIRecheck is how often the CI of a PR whose checks have all finished is +// read again although its head didn't change (e.g. someone re-ran a build). +const DefaultCIRecheck = 15 * time.Minute + +// enrichConcurrency bounds requests in flight while enriching items. +const enrichConcurrency = 4 + +// Collector refreshes the dashboard's items, re-reading a PR's CI only when it may +// have changed: new head commit, checks still running, or DefaultCIRecheck elapsed. +// That keeps a refresh every minute within Bitbucket's 1,000 requests/hour for +// dozens of PRs. +type Collector struct { + Sources []Source + Now func() time.Time + CIRecheck time.Duration + + mu sync.Mutex + ci map[string]ciSnapshot + hosts map[string]Source // item host → source, for Enrich +} + +type ciSnapshot struct { + sha string + item Item // CI fields only + readAt time.Time + running bool +} + +// NewCollector returns a collector over sources. +func NewCollector(sources ...Source) *Collector { + return &Collector{Sources: sources, Now: time.Now, CIRecheck: DefaultCIRecheck, ci: map[string]ciSnapshot{}} +} + +// Refresh discovers the PRs of every source and enriches the open ones. Items are +// returned even if some requests failed; problems are reported as warnings. +func (c *Collector) Refresh(ctx context.Context, f Filter) ([]Item, []string) { + var items []Item + var warnings []string + sourceOf := map[string]Source{} + for _, src := range c.Sources { + found, warns, err := src.Discover(ctx, f) + for _, w := range warns { + warnings = append(warnings, src.Name()+": "+w) + } + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", src.Name(), err)) + continue + } + for _, it := range found { + sourceOf[it.Key()] = src + } + items = append(items, dedupe(found)...) + } + + var wg sync.WaitGroup + var wmu sync.Mutex + sem := make(chan struct{}, enrichConcurrency) + for i := range items { + it := &items[i] + if it.State != StateOpen { + continue + } + needCI := c.needsCI(*it) + if !needCI { + c.applyCachedCI(it) + } + wg.Add(1) + sem <- struct{}{} + go func(it *Item, src Source, needCI bool) { + defer wg.Done() + defer func() { <-sem }() + if err := src.Enrich(ctx, it, needCI); err != nil { + wmu.Lock() + warnings = append(warnings, fmt.Sprintf("%s %s: %v", src.Name(), it.Ref, err)) + wmu.Unlock() + if needCI { + c.applyCachedCI(it) // keep showing the last known CI + } + return + } + if needCI { + c.storeCI(*it) + } + }(it, sourceOf[it.Key()], needCI) + } + wg.Wait() + sort.Strings(warnings) + return items, warnings +} + +// dedupe drops repeated PRs (e.g. both yours and to review), keeping the first. +func dedupe(items []Item) []Item { + seen := map[string]bool{} + out := items[:0] + for _, it := range items { + if seen[it.Key()] { + continue + } + seen[it.Key()] = true + out = append(out, it) + } + return out +} + +func (c *Collector) needsCI(it Item) bool { + c.mu.Lock() + defer c.mu.Unlock() + snap, ok := c.ci[it.Key()] + switch { + case !ok, snap.sha != it.HeadSHA, snap.running: + return true + } + return c.Now().Sub(snap.readAt) >= c.CIRecheck +} + +func (c *Collector) applyCachedCI(it *Item) { + c.mu.Lock() + defer c.mu.Unlock() + if snap, ok := c.ci[it.Key()]; ok && snap.sha == it.HeadSHA { + it.CI, it.CIDone, it.CITotal, it.FailedChecks = snap.item.CI, snap.item.CIDone, snap.item.CITotal, snap.item.FailedChecks + } +} + +func (c *Collector) storeCI(it Item) { + c.mu.Lock() + defer c.mu.Unlock() + c.ci[it.Key()] = ciSnapshot{sha: it.HeadSHA, item: it, readAt: c.Now(), running: it.CI == "pending" || it.CI == ""} +} + +// Requests reports each source's requests in the last hour, e.g. "Bitbucket 312/h". +func (c *Collector) Requests() []string { + var out []string + for _, src := range c.Sources { + out = append(out, fmt.Sprintf("%s %d/h", src.Name(), src.RequestsInLastHour())) + } + return out +} diff --git a/internal/dashboard/collect_test.go b/internal/dashboard/collect_test.go new file mode 100644 index 0000000..1d8728a --- /dev/null +++ b/internal/dashboard/collect_test.go @@ -0,0 +1,126 @@ +package dashboard + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/devblac/prw/internal/forge" +) + +// fakeSource returns items and counts CI reads per PR. +type fakeSource struct { + mu sync.Mutex + items []Item + ci map[int]string // number → CI state Enrich reports + ciReads map[int]int + failCI bool + discover error +} + +func (f *fakeSource) Name() string { return "Fake" } +func (f *fakeSource) RequestsInLastHour() int { return 7 } +func (f *fakeSource) Discover(ctx context.Context, _ Filter) ([]Item, []string, error) { + return append([]Item(nil), f.items...), nil, f.discover +} +func (f *fakeSource) Enrich(ctx context.Context, it *Item, needCI bool) error { + f.mu.Lock() + defer f.mu.Unlock() + if needCI { + f.ciReads[it.Number]++ + if f.failCI { + return errors.New("boom") + } + it.CI = f.ci[it.Number] + } + it.Review = "none" + return nil +} + +func pr(number int, sha, state string) Item { + ref := forge.Ref{Host: forge.Bitbucket, Owner: "ws", Repo: "api", Number: number} + return Item{Ref: ref, Repo: "ws/api", Number: number, HeadSHA: sha, State: state, Target: "dev"} +} + +func newFake(items ...Item) *fakeSource { + return &fakeSource{items: items, ci: map[int]string{}, ciReads: map[int]int{}} +} + +func TestCollector_ReadsCIOnlyWhenItMayHaveChanged(t *testing.T) { + src := newFake(pr(1, "a", StateOpen), pr(2, "b", StateOpen), pr(3, "c", StateMerged)) + src.ci = map[int]string{1: "success", 2: "pending"} + now := time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC) + c := NewCollector(src) + c.Now = func() time.Time { return now } + + items, warnings := c.Refresh(context.Background(), Filter{Mine: true}) + if len(warnings) != 0 || items[0].CI != "success" || items[1].CI != "pending" { + t.Fatalf("items=%+v warnings=%v", items, warnings) + } + if src.ciReads[3] != 0 { + t.Error("merged PRs have no CI to read") + } + + now = now.Add(time.Minute) + items, _ = c.Refresh(context.Background(), Filter{Mine: true}) + if src.ciReads[1] != 1 || src.ciReads[2] != 2 { + t.Errorf("reads after a minute: finished=%d running=%d; want 1 (cached) and 2 (still running)", src.ciReads[1], src.ciReads[2]) + } + if items[0].CI != "success" { + t.Errorf("cached CI must still be shown: %+v", items[0]) + } + + src.items[0].HeadSHA = "a2" // new push + c.Refresh(context.Background(), Filter{Mine: true}) + if src.ciReads[1] != 2 { + t.Errorf("a new head must be read: %d reads", src.ciReads[1]) + } + + now = now.Add(DefaultCIRecheck) + c.Refresh(context.Background(), Filter{Mine: true}) + if src.ciReads[1] != 3 { + t.Errorf("finished CI must be re-read after %s (a build may have been re-run): %d reads", DefaultCIRecheck, src.ciReads[1]) + } +} + +func TestCollector_FailedCIReadKeepsLastKnownState(t *testing.T) { + src := newFake(pr(1, "a", StateOpen)) + src.ci = map[int]string{1: "pending"} + c := NewCollector(src) + c.Refresh(context.Background(), Filter{Mine: true}) + + src.failCI = true + items, warnings := c.Refresh(context.Background(), Filter{Mine: true}) + if items[0].CI != "pending" || len(warnings) != 1 || !strings.Contains(warnings[0], "boom") { + t.Errorf("items=%+v warnings=%v", items, warnings) + } +} + +func TestCollector_SourceFailureIsAWarning(t *testing.T) { + bad := newFake() + bad.discover = errors.New("401 Unauthorized") + good := newFake(pr(1, "a", StateOpen)) + items, warnings := NewCollector(bad, good).Refresh(context.Background(), Filter{Mine: true}) + if len(items) != 1 || len(warnings) != 1 || !strings.Contains(warnings[0], "Fake: 401") { + t.Errorf("items=%d warnings=%v", len(items), warnings) + } +} + +func TestCollector_Dedupes(t *testing.T) { + mine := pr(1, "a", StateOpen) + review := mine + review.Role = RoleReview + items, _ := NewCollector(newFake(mine, review)).Refresh(context.Background(), Filter{Mine: true, Review: true}) + if len(items) != 1 { + t.Errorf("items = %d, want the PR once", len(items)) + } +} + +func TestCollector_Requests(t *testing.T) { + if got := strings.Join(NewCollector(newFake()).Requests(), ","); got != "Fake 7/h" { + t.Errorf("Requests = %s", got) + } +} diff --git a/internal/dashboard/group.go b/internal/dashboard/group.go new file mode 100644 index 0000000..ce5d866 --- /dev/null +++ b/internal/dashboard/group.go @@ -0,0 +1,158 @@ +package dashboard + +import ( + "fmt" + "regexp" + "sort" + "strings" + "time" +) + +// Grouping modes (--group-by / group_by). +const ( + GroupAuto = "auto" // issue key, else branch + GroupIssue = "issue" // issue key only; PRs without one are grouped alone + GroupBranch = "branch" // source branch without cherry-pick decorations + GroupTitle = "title" + GroupNone = "none" // one row per PR +) + +// DefaultIssuePattern matches issue keys like PROJ-123 (Jira, Linear, YouTrack, +// Shortcut's sc-123 is matched case-insensitively by the branch fallback instead). +const DefaultIssuePattern = `\b[A-Z][A-Z0-9]{1,9}-[0-9]+\b` + +// ValidGroupBy reports whether mode is a grouping mode. +func ValidGroupBy(mode string) bool { + switch mode { + case GroupAuto, GroupIssue, GroupBranch, GroupTitle, GroupNone: + return true + } + return false +} + +// Grouper assigns items to tasks. +type Grouper struct { + Mode string + Issue *regexp.Regexp +} + +// NewGrouper compiles pattern (DefaultIssuePattern when empty). +func NewGrouper(mode, pattern string) (*Grouper, error) { + if mode == "" { + mode = GroupAuto + } + if !ValidGroupBy(mode) { + return nil, fmt.Errorf("invalid grouping %q (expected auto, issue, branch, title or none)", mode) + } + if pattern == "" { + pattern = DefaultIssuePattern + } + re, err := regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("invalid issue_pattern: %w", err) + } + return &Grouper{Mode: mode, Issue: re}, nil +} + +// Task returns the task an item belongs to. +func (g *Grouper) Task(it Item) string { + switch g.Mode { + case GroupNone: + return fmt.Sprintf("#%d", it.Number) + case GroupTitle: + return strings.TrimSpace(it.Title) + case GroupBranch: + return BranchBase(it.Source, it.Target) + } + // The branch first: titles of cherry-picks sometimes mention other keys. + for _, s := range []string{it.Source, it.Title} { + if k := g.Issue.FindString(s); k != "" { + return strings.ToUpper(k) + } + } + if g.Mode == GroupIssue { + return fmt.Sprintf("#%d", it.Number) + } + return BranchBase(it.Source, it.Target) +} + +var ( + cherryPrefix = regexp.MustCompile(`(?i)^(cherry[-_]?pick(ed)?|backport|cp)[-_/]+`) + // cherrySuffix strips "-to-rc", "-rc", "_master", "-cherry-pick", "-backport-2", + // a trailing short hash or number, repeatedly. + cherrySuffix = regexp.MustCompile(`(?i)[-_/](to[-_])?(dev|develop|development|staging|stage|qa|uat|rc|release|main|master|prod|production|cherry[-_]?pick(ed)?|backport|cp|[0-9a-f]{7,40}|[0-9]{1,3})$`) +) + +// BranchBase is a source branch without cherry-pick / backport decorations, so a +// branch and its cherry-picks to other targets share a task: +// "cherry-pick/feature/login-rc" and "feature/login" → "feature/login". +func BranchBase(source, target string) string { + b := strings.TrimSpace(source) + for { + stripped := cherryPrefix.ReplaceAllString(b, "") + if stripped == b { + break + } + b = stripped + } + if target != "" { + for _, sep := range []string{"-to-", "_to_", "-", "_", "/"} { + b = strings.TrimSuffix(b, sep+target) + } + } + for i := 0; i < 5; i++ { + stripped := cherrySuffix.ReplaceAllString(b, "") + if stripped == b || stripped == "" { + break + } + b = stripped + } + return strings.ToLower(b) +} + +// Row is one line of the dashboard: a task in a repository. +type Row struct { + Task string + Repo string + Items []Item + // Updated is the most recent update of its items. + Updated time.Time +} + +// Rows groups items by task and repository. Rows are ordered by task, the tasks +// with the most recent activity first; rows of the same task by repository. +func (g *Grouper) Rows(items []Item) []Row { + type key struct{ task, repo string } + index := map[key]int{} + var rows []Row + for _, it := range items { + k := key{g.Task(it), it.Repo} + i, ok := index[k] + if !ok { + i = len(rows) + index[k] = i + rows = append(rows, Row{Task: k.task, Repo: k.repo}) + } + rows[i].Items = append(rows[i].Items, it) + if it.UpdatedAt.After(rows[i].Updated) { + rows[i].Updated = it.UpdatedAt + } + } + taskUpdated := map[string]time.Time{} + for _, r := range rows { + if r.Updated.After(taskUpdated[r.Task]) { + taskUpdated[r.Task] = r.Updated + } + } + sort.SliceStable(rows, func(i, j int) bool { + a, b := rows[i], rows[j] + if a.Task != b.Task { + if !taskUpdated[a.Task].Equal(taskUpdated[b.Task]) { + return taskUpdated[a.Task].After(taskUpdated[b.Task]) + } + return a.Task < b.Task + } + return a.Repo < b.Repo + }) + return rows +} diff --git a/internal/dashboard/group_test.go b/internal/dashboard/group_test.go new file mode 100644 index 0000000..686361e --- /dev/null +++ b/internal/dashboard/group_test.go @@ -0,0 +1,148 @@ +package dashboard + +import ( + "strconv" + "strings" + "testing" + "time" +) + +func item(repo string, number int, source, target, title string) Item { + return Item{Repo: repo, Number: number, Source: source, Target: target, Title: title} +} + +func TestTask_Auto(t *testing.T) { + g, err := NewGrouper("", "") + if err != nil { + t.Fatal(err) + } + tests := []struct { + it Item + want string + }{ + // Issue key in the branch (as seen on Bitbucket: ONECLOUD-13509/operate-without-descriptor). + {item("a/api", 1, "ONECLOUD-13509/operate-without-descriptor", "master", "ACE can operate"), "ONECLOUD-13509"}, + {item("a/api", 2, "feature/SEC-1234-login", "dev", "Login"), "SEC-1234"}, + {item("a/api", 3, "cherry-pick/SEC-1234-rc", "rc", "Login (rc)"), "SEC-1234"}, + // Key only in the title. + {item("a/api", 4, "fix-timeout", "dev", "[PAY-77] Fix timeout"), "PAY-77"}, + // The branch's key wins over others mentioned in the title. + {item("a/api", 5, "SEC-9/x", "main", "Revert SEC-8"), "SEC-9"}, + // No key: the branch without cherry-pick decorations. + {item("a/api", 6, "feature/login", "dev", "Login"), "feature/login"}, + {item("a/api", 7, "cherry-pick/feature/login-rc", "rc", "Login"), "feature/login"}, + {item("a/api", 8, "feature/login-to-master", "master", "Login"), "feature/login"}, + {item("a/api", 9, "backport-feature/login-main", "main", "Login"), "feature/login"}, + {item("a/api", 10, "renovate/node-24.x", "master", "Update Node.js"), "renovate/node-24.x"}, + } + for _, tt := range tests { + if got := g.Task(tt.it); got != tt.want { + t.Errorf("Task(%q, %q) = %q, want %q", tt.it.Source, tt.it.Title, got, tt.want) + } + } +} + +func TestTask_Modes(t *testing.T) { + it := item("a/api", 3, "cherry-pick/SEC-1234-rc", "rc", "Login") + for mode, want := range map[string]string{ + GroupIssue: "SEC-1234", GroupBranch: "sec-1234", GroupTitle: "Login", GroupNone: "#3", + } { + g, _ := NewGrouper(mode, "") + if got := g.Task(it); got != want { + t.Errorf("%s: %q, want %q", mode, got, want) + } + } + g, _ := NewGrouper(GroupIssue, "") + if got := g.Task(item("a/api", 4, "feature/login", "dev", "Login")); got != "#4" { + t.Errorf("issue mode without a key = %q, want the PR alone", got) + } + if _, err := NewGrouper("bogus", ""); err == nil { + t.Error("invalid mode must fail") + } +} + +func TestTask_CustomIssuePattern(t *testing.T) { + // e.g. a team using GitHub issue numbers in branches: "123-fix-login". + g, err := NewGrouper(GroupAuto, `^[0-9]+`) + if err != nil { + t.Fatal(err) + } + if got := g.Task(item("a/api", 1, "123-fix-login", "dev", "Fix")); got != "123" { + t.Errorf("custom pattern: %q", got) + } + if _, err := NewGrouper(GroupAuto, `[`); err == nil { + t.Error("an invalid pattern must fail") + } +} + +func TestRows_GroupsTasksAcrossBranchesAndRepos(t *testing.T) { + g, _ := NewGrouper("", "") + now := time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC) + at := func(it Item, ago time.Duration) Item { it.UpdatedAt = now.Add(-ago); return it } + rows := g.Rows([]Item{ + at(item("sec/api", 1, "SEC-1/a", "dev", ""), 3*time.Hour), + at(item("sec/api", 2, "cherry-pick/SEC-1-rc", "rc", ""), 2*time.Hour), + at(item("sec/web", 3, "SEC-1/a", "dev", ""), 4*time.Hour), + at(item("sec/api", 4, "SEC-2/b", "dev", ""), time.Hour), // most recent task first + at(item("sec/api", 5, "SEC-1-master", "master", ""), 5*time.Hour), + }) + var got []string + for _, r := range rows { + got = append(got, r.Task+" "+r.Repo+" "+itemNumbers(r.Items)) + } + want := "SEC-2 sec/api 4|SEC-1 sec/api 1,2,5|SEC-1 sec/web 3" + if strings.Join(got, "|") != want { + t.Errorf("rows = %s\nwant %s", strings.Join(got, "|"), want) + } +} + +func itemNumbers(items []Item) string { + var s []string + for _, it := range items { + s = append(s, strconv.Itoa(it.Number)) + } + return strings.Join(s, ",") +} + +func TestColumns_TiersShareColumns(t *testing.T) { + cols := Columns([]string{"master", "dev", "rc", "main", "release/1.2", "feature-x", "develop", "release"}, nil) + var titles []string + for _, c := range cols { + titles = append(titles, c.Title) + } + if got := strings.Join(titles, " "); got != "dev|develop rc|release|release/* main|master other" { + t.Errorf("columns = %s", got) + } + if ColumnOf(cols, "release/1.2") != 1 || ColumnOf(cols, "main") != 2 || ColumnOf(cols, "nope") != -1 { + t.Error("ColumnOf") + } +} + +func TestColumns_YourTwoJobs(t *testing.T) { + // Job 1: dev → rc → master. Job 2: dev → release → master or main. + for _, tc := range []struct { + targets []string + want string + }{ + {[]string{"master", "rc", "dev"}, "dev rc master"}, + {[]string{"main", "release", "dev"}, "dev release main"}, + } { + var titles []string + for _, c := range Columns(tc.targets, nil) { + titles = append(titles, c.Title) + } + if got := strings.Join(titles, " "); got != tc.want { + t.Errorf("%v → %s, want %s", tc.targets, got, tc.want) + } + } +} + +func TestColumns_CustomOrder(t *testing.T) { + var titles []string + for _, c := range Columns([]string{"main", "uat", "qa", "dev", "hotfix"}, []string{"dev", "qa", "uat", "main"}) { + titles = append(titles, c.Title) + } + if got := strings.Join(titles, " "); got != "dev qa uat main other" { + t.Errorf("columns = %s", got) + } +} diff --git a/internal/dashboard/model.go b/internal/dashboard/model.go new file mode 100644 index 0000000..b9946e8 --- /dev/null +++ b/internal/dashboard/model.go @@ -0,0 +1,66 @@ +// Package dashboard builds `prw dashboard`: a live table of the pull requests you +// care about (yours, the ones to review, recently merged ones) across GitHub and +// Bitbucket, grouped by task, with one column per target branch. +// +// Grouping and branch ordering are heuristics with escape hatches, so they work +// for most teams without configuration: +// +// - A task is the issue key in the PR's source branch or title (e.g. PROJ-123, +// as used by Jira, Linear, YouTrack...), else the source branch without +// cherry-pick / backport decorations. issue_pattern and --group-by override it. +// - Target branches are ranked dev → staging/qa → rc/release → main/master/prod, +// and branches of the same rank share a column (dev|develop, main|master). +// branch_order overrides it. +package dashboard + +import ( + "time" + + "github.com/devblac/prw/internal/forge" +) + +// Roles of a PR in the dashboard. +const ( + RoleMine = "mine" + RoleReview = "review" +) + +// PR states. +const ( + StateOpen = "open" + StateMerged = "merged" + StateClosed = "closed" +) + +// Item is one pull request on the dashboard. +type Item struct { + Ref forge.Ref `json:"-"` + URL string `json:"url"` + Host string `json:"host"` + Repo string `json:"repo"` // owner/repo + Number int `json:"number"` + Title string `json:"title"` + Source string `json:"source_branch"` + Target string `json:"target_branch"` + State string `json:"state"` // open, merged, closed + Role string `json:"role"` // mine, review + UpdatedAt time.Time `json:"updated_at"` + HeadSHA string `json:"sha,omitempty"` + + // CI is the aggregated state (success, failure, error, pending, no_ci), empty + // until known; CIDone/CITotal count finished checks. + CI string `json:"ci,omitempty"` + CIDone int `json:"ci_done,omitempty"` + CITotal int `json:"ci_total,omitempty"` + FailedChecks []string `json:"failed_checks,omitempty"` + // Review is approved, changes_requested or none; Approvals counts approvals. + Review string `json:"review,omitempty"` + Approvals int `json:"approvals,omitempty"` + // Merge is ready, conflict, blocked, behind, unstable or draft; empty if unknown. + Merge string `json:"merge,omitempty"` +} + +// Key identifies an item across refreshes. +func (it Item) Key() string { + return forge.NormalizeHost(it.Ref.Host) + "/" + it.Ref.String() +} diff --git a/internal/dashboard/render.go b/internal/dashboard/render.go new file mode 100644 index 0000000..b9c1118 --- /dev/null +++ b/internal/dashboard/render.go @@ -0,0 +1,234 @@ +package dashboard + +import ( + "fmt" + "io" + "regexp" + "strings" + "time" + "unicode/utf8" +) + +// Width limits of the task name and of a target branch named in the "other" column. +const ( + maxTaskWidth = 40 + maxOtherTarget = 24 +) + +// Section is one table of the dashboard ("Your PRs", "To review"). +type Section struct { + Title string + Rows []Row +} + +// RenderOptions controls how the dashboard is drawn. +type RenderOptions struct { + Now time.Time + Width int // terminal width; the title column is cut to fit + // Links turns PR numbers into terminal hyperlinks (OSC 8), which most modern + // terminals make clickable. + Links bool +} + +// Render draws the sections as tables with one column per target branch. +func Render(w io.Writer, sections []Section, cols []Column, opts RenderOptions) { + if opts.Width <= 0 { + opts.Width = 140 + } + for i, s := range sections { + if i > 0 { + fmt.Fprintln(w) + } + renderSection(w, s, cols, opts) + } +} + +func renderSection(w io.Writer, s Section, cols []Column, opts RenderOptions) { + fmt.Fprintf(w, "%s (%d)\n", s.Title, countItems(s.Rows)) + if len(s.Rows) == 0 { + fmt.Fprintln(w, " none") + return + } + + header := []string{"TASK", "REPO"} + for _, c := range cols { + header = append(header, c.Title) + } + header = append(header, "UPDATED", "TITLE") + + table := [][]string{header} + prevTask := "" + for _, r := range s.Rows { + line := make([]string, 0, len(header)) + task := truncate(r.Task, maxTaskWidth) + if r.Task == prevTask { + task = "" // the task is named on its first row only + } + prevTask = r.Task + line = append(line, task, shortRepo(r.Repo)) + cells := make([][]string, len(cols)) + var title string + var latest time.Time + for _, it := range r.Items { + if c := ColumnOf(cols, it.Target); c >= 0 { + cell := Cell(it, opts.Links) + if cols[c].Other { + cell += " →" + truncate(it.Target, maxOtherTarget) + } + cells[c] = append(cells[c], cell) + } + if it.UpdatedAt.After(latest) || title == "" { + latest, title = it.UpdatedAt, it.Title + } + } + for _, c := range cells { + if len(c) == 0 { + line = append(line, "—") + } else { + line = append(line, strings.Join(c, " ")) + } + } + line = append(line, Ago(opts.Now, r.Updated), title) + table = append(table, line) + } + writeTable(w, table, opts.Width) +} + +func countItems(rows []Row) int { + n := 0 + for _, r := range rows { + n += len(r.Items) + } + return n +} + +// shortRepo drops the owner / workspace when it adds nothing: most people's PRs +// live in one or two. Kept as is otherwise. +func shortRepo(repo string) string { + return repo +} + +// Cell is one PR in a branch column: its number and most important state. +func Cell(it Item, links bool) string { + num := fmt.Sprintf("#%d", it.Number) + if links && it.URL != "" { + num = "\x1b]8;;" + it.URL + "\x1b\\" + num + "\x1b]8;;\x1b\\" + } + return num + " " + Status(it) +} + +// Status summarizes a PR in a few characters, the most pressing thing first: +// failed CI, conflict, changes requested, running CI, then green. +func Status(it Item) string { + switch it.State { + case StateMerged: + return "🟣 merged" + case StateClosed: + return "⚫ closed" + } + approvals := "" + if it.Approvals > 0 { + approvals = fmt.Sprintf(" ✓%d", it.Approvals) + } + switch { + case it.CI == "failure" || it.CI == "error": + return "❌ CI failed" + approvals + case it.Merge == "conflict": + return "⛔ conflict" + approvals + case it.Review == "changes_requested": + return "📝 changes" + approvals + case it.CI == "pending": + if it.CITotal > 0 { + return fmt.Sprintf("⏳ %d/%d", it.CIDone, it.CITotal) + approvals + } + return "⏳ CI" + approvals + case it.CI == "no_ci": + if it.Merge == "ready" { + return "🚀 ready" + approvals + } + return "⚪ no CI" + approvals + case it.CI == "success": + if it.Merge == "ready" { + return "🚀 ready" + approvals + } + return "✅ CI ok" + approvals + } + return "…" + approvals // not loaded yet +} + +// Ago is a compact age: "45s", "12m", "3h", "5d". +func Ago(now, t time.Time) string { + if t.IsZero() { + return "" + } + d := now.Sub(t) + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 48*time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + default: + return fmt.Sprintf("%dd", int(d.Hours()/24)) + } +} + +// writeTable pads columns by their visible width; the last column (the title) is +// cut so lines fit width. +func writeTable(w io.Writer, table [][]string, width int) { + ncol := len(table[0]) + widths := make([]int, ncol) + for _, row := range table { + for i, cell := range row[:ncol-1] { + if n := DisplayWidth(cell); n > widths[i] { + widths[i] = n + } + } + } + used := 0 + for _, n := range widths[:ncol-1] { + used += n + 2 + } + titleWidth := width - used + for _, row := range table { + var b strings.Builder + for i, cell := range row[:ncol-1] { + b.WriteString(cell) + b.WriteString(strings.Repeat(" ", widths[i]-DisplayWidth(cell)+2)) + } + if titleWidth >= 10 { + b.WriteString(truncate(row[ncol-1], titleWidth)) + } + fmt.Fprintln(w, strings.TrimRight(b.String(), " ")) + } +} + +var escapes = regexp.MustCompile("\x1b\\]8;;[^\x1b]*\x1b\\\\|\x1b\\[[0-9;]*[A-Za-z]") + +// DisplayWidth is how many terminal columns s takes: escape sequences take none, +// emoji take two. +func DisplayWidth(s string) int { + s = escapes.ReplaceAllString(s, "") + n := 0 + for _, r := range s { + switch { + case r == 0xFE0F || r == 0x200D: + // variation selector / zero-width joiner + case r >= 0x1F000, r == 0x2705, r == 0x274C, r == 0x23F3, r == 0x231B, + r == 0x26AA, r == 0x26AB, r == 0x26D4, r == 0x2B50: + n += 2 + default: + n++ + } + } + return n +} + +func truncate(s string, max int) string { + if utf8.RuneCountInString(s) <= max { + return s + } + r := []rune(s) + return string(r[:max-1]) + "…" +} diff --git a/internal/dashboard/render_test.go b/internal/dashboard/render_test.go new file mode 100644 index 0000000..7db2a18 --- /dev/null +++ b/internal/dashboard/render_test.go @@ -0,0 +1,142 @@ +package dashboard + +import ( + "bytes" + "strings" + "testing" + "time" +) + +var renderNow = time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC) + +func open(repo string, number int, source, target string, ci string, ago time.Duration) Item { + it := item(repo, number, source, target, "Title "+source) + it.State, it.CI, it.UpdatedAt = StateOpen, ci, renderNow.Add(-ago) + it.URL = "https://example.com/pr" + return it +} + +func TestStatus(t *testing.T) { + base := Item{State: StateOpen} + tests := []struct { + mutate func(*Item) + want string + }{ + {func(i *Item) { i.State = StateMerged }, "🟣 merged"}, + {func(i *Item) { i.State = StateClosed }, "⚫ closed"}, + {func(i *Item) { i.CI, i.Merge = "failure", "conflict" }, "❌ CI failed"}, + {func(i *Item) { i.CI, i.Merge = "success", "conflict" }, "⛔ conflict"}, + {func(i *Item) { i.CI, i.Review, i.Approvals = "success", "changes_requested", 1 }, "📝 changes ✓1"}, + {func(i *Item) { i.CI, i.CIDone, i.CITotal = "pending", 3, 5 }, "⏳ 3/5"}, + {func(i *Item) { i.CI = "pending" }, "⏳ CI"}, + {func(i *Item) { i.CI, i.Approvals = "success", 2 }, "✅ CI ok ✓2"}, + {func(i *Item) { i.CI, i.Merge, i.Approvals = "success", "ready", 2 }, "🚀 ready ✓2"}, + {func(i *Item) { i.CI = "no_ci" }, "⚪ no CI"}, + {func(i *Item) {}, "…"}, + } + for _, tt := range tests { + it := base + tt.mutate(&it) + if got := Status(it); got != tt.want { + t.Errorf("Status(%+v) = %q, want %q", it, got, tt.want) + } + } +} + +func TestDisplayWidth(t *testing.T) { + for s, want := range map[string]int{ + "abc": 3, + "✅ CI ok": 8, + "⏳ 3/5": 6, + "#12 🟣 merged": 13, + "⚠️": 1, + "\x1b]8;;https://x\x1b\\#12\x1b]8;;\x1b\\": 3, + "\x1b[31mred\x1b[0m": 3, + "—": 1, + } { + if got := DisplayWidth(s); got != want { + t.Errorf("DisplayWidth(%q) = %d, want %d", s, got, want) + } + } +} + +func TestRender(t *testing.T) { + g, _ := NewGrouper("", "") + merged := open("sec/issuance-api", 10, "SEC-1234/login", "dev", "", 3*time.Hour) + merged.State = StateMerged + pending := open("sec/issuance-api", 11, "cherry-pick/SEC-1234-rc", "rc", "pending", 2*time.Hour) + pending.CIDone, pending.CITotal, pending.Approvals = 3, 5, 1 + failed := open("sec/wallet", 7, "SEC-1198/fix", "master", "failure", 20*time.Minute) + items := []Item{merged, pending, failed} + cols := Columns([]string{"dev", "rc", "master"}, nil) + + var buf bytes.Buffer + Render(&buf, []Section{{Title: "Your PRs", Rows: g.Rows(items)}}, cols, RenderOptions{Now: renderNow, Width: 120}) + out := buf.String() + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + if len(lines) != 4 || lines[0] != "Your PRs (3)" { + t.Fatalf("output:\n%s", out) + } + if !strings.HasPrefix(lines[1], "TASK") || !strings.Contains(lines[1], "dev") || !strings.Contains(lines[1], "master") { + t.Errorf("header = %q", lines[1]) + } + // Most recent task first; its PR goes in the master column. + if !strings.HasPrefix(lines[2], "SEC-1198") || !strings.Contains(lines[2], "#7 ❌ CI failed") || !strings.Contains(lines[2], "20m") { + t.Errorf("row 1 = %q", lines[2]) + } + if !strings.HasPrefix(lines[3], "SEC-1234") || !strings.Contains(lines[3], "#10 🟣 merged") || !strings.Contains(lines[3], "#11 ⏳ 3/5 ✓1") { + t.Errorf("row 2 = %q", lines[3]) + } + // Columns line up: every row is as wide as the header up to the title. + col := strings.Index(lines[1], "UPDATED") + for _, l := range lines[2:4] { + if w := DisplayWidth(l[:strings.LastIndex(l, " ")]); w < col { + t.Errorf("misaligned row %q", l) + } + } +} + +func TestRender_LinksDontBreakAlignment(t *testing.T) { + g, _ := NewGrouper("", "") + items := []Item{open("o/r", 1, "A-1/x", "dev", "success", time.Hour), open("o/r", 22, "A-2/y", "dev", "success", 2*time.Hour)} + var plain, linked bytes.Buffer + cols := Columns([]string{"dev"}, nil) + Render(&plain, []Section{{Title: "PRs", Rows: g.Rows(items)}}, cols, RenderOptions{Now: renderNow}) + Render(&linked, []Section{{Title: "PRs", Rows: g.Rows(items)}}, cols, RenderOptions{Now: renderNow, Links: true}) + stripped := escapes.ReplaceAllString(linked.String(), "") + if stripped != plain.String() { + t.Errorf("hyperlinks changed the layout:\n%s\nvs\n%s", stripped, plain.String()) + } + if !strings.Contains(linked.String(), "\x1b]8;;https://example.com/pr\x1b\\#1") { + t.Error("PR numbers should be hyperlinks") + } +} + +func TestRender_EmptySection(t *testing.T) { + var buf bytes.Buffer + Render(&buf, []Section{{Title: "To review"}}, nil, RenderOptions{Now: renderNow}) + if buf.String() != "To review (0)\n none\n" { + t.Errorf("output = %q", buf.String()) + } +} + +func TestAgo(t *testing.T) { + for d, want := range map[time.Duration]string{ + 30 * time.Second: "30s", 5 * time.Minute: "5m", 3 * time.Hour: "3h", 47 * time.Hour: "47h", 72 * time.Hour: "3d", + } { + if got := Ago(renderNow, renderNow.Add(-d)); got != want { + t.Errorf("Ago(%s) = %q, want %q", d, got, want) + } + } +} + +func TestRender_OtherColumnNamesTheTarget(t *testing.T) { + g, _ := NewGrouper("", "") + stacked := open("o/r", 5, "feat/b", "feat/a", "success", time.Hour) + cols := Columns([]string{"main", "feat/a"}, nil) + var buf bytes.Buffer + Render(&buf, []Section{{Title: "PRs", Rows: g.Rows([]Item{stacked})}}, cols, RenderOptions{Now: renderNow, Width: 160}) + if !strings.Contains(buf.String(), "other") || !strings.Contains(buf.String(), "#5 ✅ CI ok →feat/a") { + t.Errorf("output:\n%s", buf.String()) + } +} diff --git a/internal/dashboard/sources.go b/internal/dashboard/sources.go new file mode 100644 index 0000000..7a15887 --- /dev/null +++ b/internal/dashboard/sources.go @@ -0,0 +1,322 @@ +package dashboard + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/devblac/prw/internal/bitbucket" + "github.com/devblac/prw/internal/forge" + "github.com/devblac/prw/internal/github" +) + +// ciAPI is what both sources need to read a commit's CI. +type ciAPI interface { + GetCombinedStatus(ctx context.Context, owner, repo, ref string) (*github.CombinedStatus, error) + GetCheckRuns(ctx context.Context, owner, repo, ref string) ([]github.CheckRun, error) +} + +// readCI fills the item's CI fields from its head commit's checks and statuses. +func readCI(ctx context.Context, api ciAPI, it *Item) error { + ci, err := github.FetchCIStatus(ctx, api, it.Ref.Owner, it.Ref.Repo, it.HeadSHA) + if err != nil { + return err + } + github.ApplyNoCI(ci, it.UpdatedAt, time.Now()) + it.CI, it.CITotal, it.CIDone = ci.State, ci.Total, ci.Total-ci.Pending + it.FailedChecks = nil + for _, f := range ci.FailedChecks { + it.FailedChecks = append(it.FailedChecks, f.Name) + } + return nil +} + +// GitHubAPI is what the GitHub source needs from the client. +type GitHubAPI interface { + ciAPI + SearchPullRequests(ctx context.Context, query string) (*github.SearchResult, error) + GetPullRequest(ctx context.Context, owner, repo string, number int) (*github.PullRequest, error) + GetReviews(ctx context.Context, owner, repo string, number int) ([]github.Review, error) + RequestsInLastHour() int +} + +// GitHubSource finds PRs with GitHub's search, across all repositories. +type GitHubSource struct { + API GitHubAPI +} + +func (s *GitHubSource) Name() string { return "GitHub" } +func (s *GitHubSource) RequestsInLastHour() int { return s.API.RequestsInLastHour() } + +// Discover runs one search per selected filter and reads each PR (branches, head, +// merge state); unchanged PRs answer 304, which is free. +func (s *GitHubSource) Discover(ctx context.Context, f Filter) ([]Item, []string, error) { + type query struct{ q, role, state string } + var queries []query + if f.Mine { + queries = append(queries, query{"is:pr is:open archived:false author:@me", RoleMine, StateOpen}) + } + if f.Review { + queries = append(queries, query{"is:pr is:open archived:false review-requested:@me", RoleReview, StateOpen}) + } + if !f.MergedSince.IsZero() { + queries = append(queries, query{"is:pr is:merged author:@me merged:>=" + f.MergedSince.UTC().Format("2006-01-02"), RoleMine, StateMerged}) + } + + var items []Item + var warnings []string + for _, q := range queries { + res, err := s.API.SearchPullRequests(ctx, q.q) + if err != nil { + return nil, warnings, err + } + if res.Incomplete { + warnings = append(warnings, fmt.Sprintf("search %q was incomplete", q.q)) + } + found := make([]Item, len(res.PRs)) + errs := make([]error, len(res.PRs)) + var wg sync.WaitGroup + sem := make(chan struct{}, enrichConcurrency) + for i, r := range res.PRs { + wg.Add(1) + sem <- struct{}{} + go func(i int, r github.SearchResultPR) { + defer wg.Done() + defer func() { <-sem }() + pr, err := s.API.GetPullRequest(ctx, r.Owner, r.Repo, r.Number) + if err != nil { + errs[i] = err + return + } + ref := forge.Ref{Host: forge.GitHub, Owner: r.Owner, Repo: r.Repo, Number: r.Number} + found[i] = Item{ + Ref: ref, URL: ref.URL(), Host: forge.GitHub, Repo: r.Owner + "/" + r.Repo, Number: r.Number, + Title: pr.Title, Source: pr.Head.Ref, Target: pr.Base.Ref, State: q.state, Role: q.role, + UpdatedAt: pr.UpdatedAt, HeadSHA: pr.Head.SHA, Merge: github.MergeStatus(pr), + } + }(i, r) + } + wg.Wait() + for i, it := range found { + if errs[i] != nil { + warnings = append(warnings, fmt.Sprintf("%s/%s#%d: %v", res.PRs[i].Owner, res.PRs[i].Repo, res.PRs[i].Number, errs[i])) + continue + } + if q.state == StateMerged && it.UpdatedAt.Before(f.MergedSince) { + continue + } + items = append(items, it) + } + } + return items, warnings, nil +} + +// Enrich reads CI (when needed) and reviews. +func (s *GitHubSource) Enrich(ctx context.Context, it *Item, needCI bool) error { + if needCI { + if err := readCI(ctx, s.API, it); err != nil { + return err + } + } + reviews, err := s.API.GetReviews(ctx, it.Ref.Owner, it.Ref.Repo, it.Number) + if err != nil { + return err + } + it.Review = github.ReviewDecision(reviews) + it.Approvals = 0 + for _, r := range latestVerdicts(reviews) { + if r == "APPROVED" { + it.Approvals++ + } + } + return nil +} + +// latestVerdicts returns each reviewer's latest approve / request-changes verdict. +func latestVerdicts(reviews []github.Review) map[string]string { + v := map[string]string{} + for _, r := range reviews { + switch r.State { + case "APPROVED", "CHANGES_REQUESTED": + v[r.User.Login] = r.State + case "DISMISSED": + delete(v, r.User.Login) + } + } + return v +} + +// BitbucketAPI is what the Bitbucket source needs from the client. +type BitbucketAPI interface { + ciAPI + CurrentUser(ctx context.Context) (string, error) + Workspaces(ctx context.Context) ([]string, error) + UserPullRequests(ctx context.Context, workspace, user, state string, updatedSince time.Time) ([]bitbucket.ListedPR, error) + RecentRepos(ctx context.Context, workspace string, since time.Time, max int) ([]string, error) + ReviewPullRequests(ctx context.Context, workspace, repo, user string) ([]bitbucket.ListedPR, error) + MergeStateWithin(ctx context.Context, workspace, repo string, number int, updatedOn time.Time, ttl time.Duration) string + RequestsInLastHour() int +} + +// Bitbucket discovery tuning: Bitbucket can only list a user's PRs per workspace and +// find PRs to review per repository, so the review scan is the expensive part. +const ( + DefaultReviewScanEvery = 10 * time.Minute + reviewRepoWindow = 30 * 24 * time.Hour // repositories active in the last 30 days + maxReviewRepos = 60 + dashboardMergeTTL = 30 * time.Minute +) + +// BitbucketSource finds PRs per workspace: yours with one request per workspace, +// those to review by scanning recently active repositories every ReviewScanEvery. +type BitbucketSource struct { + API BitbucketAPI + // User is a UUID or account id; empty means the token's owner. + User string + // Workspaces to look in; empty means all of the token owner's workspaces. + Workspaces []string + ReviewScanEvery time.Duration + Now func() time.Time + + lastReviewScan time.Time + reviewItems []Item +} + +func (s *BitbucketSource) Name() string { return "Bitbucket" } +func (s *BitbucketSource) RequestsInLastHour() int { return s.API.RequestsInLastHour() } + +func (s *BitbucketSource) now() time.Time { + if s.Now != nil { + return s.Now() + } + return time.Now() +} + +func (s *BitbucketSource) resolve(ctx context.Context) error { + if s.User == "" { + user, err := s.API.CurrentUser(ctx) + if err != nil { + return fmt.Errorf("finding out who you are needs a Bitbucket token (prw config set bitbucket_token), or set bitbucket_user: %w", err) + } + s.User = user + } + if len(s.Workspaces) == 0 { + ws, err := s.API.Workspaces(ctx) + if err != nil || len(ws) == 0 { + if err == nil { + err = fmt.Errorf("none found") + } + return fmt.Errorf("listing your workspaces failed; set them with 'prw config set bitbucket_workspaces ws1,ws2': %w", err) + } + s.Workspaces = ws + } + return nil +} + +func (s *BitbucketSource) Discover(ctx context.Context, f Filter) ([]Item, []string, error) { + if err := s.resolve(ctx); err != nil { + return nil, nil, err + } + var items []Item + var warnings []string + for _, ws := range s.Workspaces { + if f.Mine { + prs, err := s.API.UserPullRequests(ctx, ws, s.User, "OPEN", time.Time{}) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", ws, err)) + } + items = append(items, toItems(prs, RoleMine)...) + } + if !f.MergedSince.IsZero() { + prs, err := s.API.UserPullRequests(ctx, ws, s.User, "MERGED", f.MergedSince) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s merged: %v", ws, err)) + } + items = append(items, toItems(prs, RoleMine)...) + } + } + if f.Review { + every := s.ReviewScanEvery + if every <= 0 { + every = DefaultReviewScanEvery + } + if s.lastReviewScan.IsZero() || s.now().Sub(s.lastReviewScan) >= every { + found, warns := s.scanReviews(ctx) + warnings = append(warnings, warns...) + s.reviewItems, s.lastReviewScan = found, s.now() + } + items = append(items, s.reviewItems...) + } + return items, warnings, nil +} + +func (s *BitbucketSource) scanReviews(ctx context.Context) ([]Item, []string) { + var items []Item + var warnings []string + for _, ws := range s.Workspaces { + repos, err := s.API.RecentRepos(ctx, ws, s.now().Add(-reviewRepoWindow), maxReviewRepos) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s repositories: %v", ws, err)) + continue + } + for _, repo := range repos { + prs, err := s.API.ReviewPullRequests(ctx, ws, repo, s.User) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s/%s reviews: %v", ws, repo, err)) + continue + } + items = append(items, toItems(prs, RoleReview)...) + } + } + return items, warnings +} + +func toItems(prs []bitbucket.ListedPR, role string) []Item { + var items []Item + for _, p := range prs { + ref := forge.Ref{Host: forge.Bitbucket, Owner: p.Workspace, Repo: p.Repo, Number: p.Number} + it := Item{ + Ref: ref, URL: ref.URL(), Host: forge.Bitbucket, Repo: p.Workspace + "/" + p.Repo, Number: p.Number, + Title: p.Title, Source: p.Source, Target: p.Target, Role: role, UpdatedAt: p.UpdatedOn, HeadSHA: p.SHA, + Approvals: p.Approvals, + } + switch p.State { + case "OPEN": + it.State = StateOpen + case "MERGED": + it.State = StateMerged + default: + it.State = StateClosed + } + switch { + case p.ChangesRequested: + it.Review = "changes_requested" + case p.Approvals > 0: + it.Review = "approved" + default: + it.Review = "none" + } + items = append(items, it) + } + return items +} + +// Enrich reads CI (when needed) and the merge state (cached for 30 minutes while +// the PR is unchanged). Approvals came with the list. +func (s *BitbucketSource) Enrich(ctx context.Context, it *Item, needCI bool) error { + if needCI { + if err := readCI(ctx, s.API, it); err != nil { + return err + } + } + switch s.API.MergeStateWithin(ctx, it.Ref.Owner, it.Ref.Repo, it.Number, it.UpdatedAt, dashboardMergeTTL) { + case "dirty": + it.Merge = "conflict" + case "blocked": + it.Merge = "blocked" + case "clean": + it.Merge = "ready" + } + return nil +} diff --git a/internal/dashboard/sources_test.go b/internal/dashboard/sources_test.go new file mode 100644 index 0000000..b2ed5c6 --- /dev/null +++ b/internal/dashboard/sources_test.go @@ -0,0 +1,232 @@ +package dashboard + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/devblac/prw/internal/bitbucket" + "github.com/devblac/prw/internal/github" +) + +// fakeBitbucket implements BitbucketAPI. +type fakeBitbucket struct { + user, userErr string + workspaces []string + open, merged map[string][]bitbucket.ListedPR // by workspace + repos []string + review map[string][]bitbucket.ListedPR // by repo + merge string + runs []github.CheckRun + repoScans int + mergedSinceAsked time.Time +} + +func (f *fakeBitbucket) CurrentUser(ctx context.Context) (string, error) { + if f.userErr != "" { + return "", errors.New(f.userErr) + } + return f.user, nil +} +func (f *fakeBitbucket) Workspaces(ctx context.Context) ([]string, error) { return f.workspaces, nil } +func (f *fakeBitbucket) UserPullRequests(ctx context.Context, ws, user, state string, since time.Time) ([]bitbucket.ListedPR, error) { + if user != "{me}" { + return nil, fmt.Errorf("unexpected user %s", user) + } + if state == "MERGED" { + f.mergedSinceAsked = since + return f.merged[ws], nil + } + return f.open[ws], nil +} +func (f *fakeBitbucket) RecentRepos(ctx context.Context, ws string, since time.Time, max int) ([]string, error) { + f.repoScans++ + return f.repos, nil +} +func (f *fakeBitbucket) ReviewPullRequests(ctx context.Context, ws, repo, user string) ([]bitbucket.ListedPR, error) { + return f.review[repo], nil +} +func (f *fakeBitbucket) MergeStateWithin(ctx context.Context, ws, repo string, n int, updated time.Time, ttl time.Duration) string { + return f.merge +} +func (f *fakeBitbucket) GetCombinedStatus(ctx context.Context, o, r, ref string) (*github.CombinedStatus, error) { + return &github.CombinedStatus{State: "pending"}, nil +} +func (f *fakeBitbucket) GetCheckRuns(ctx context.Context, o, r, ref string) ([]github.CheckRun, error) { + return f.runs, nil +} +func (f *fakeBitbucket) RequestsInLastHour() int { return 3 } + +func listed(ws, repo string, n int, source, target, state string) bitbucket.ListedPR { + return bitbucket.ListedPR{Workspace: ws, Repo: repo, Number: n, Source: source, Target: target, State: state, SHA: "sha", Title: "T"} +} + +func TestBitbucketSource_Discover(t *testing.T) { + approved := listed("sec", "api", 2, "SEC-1-rc", "rc", "OPEN") + approved.Approvals = 2 + changes := listed("sec", "web", 3, "SEC-2", "dev", "OPEN") + changes.ChangesRequested = true + api := &fakeBitbucket{ + user: "{me}", workspaces: []string{"sec"}, + open: map[string][]bitbucket.ListedPR{"sec": {listed("sec", "api", 1, "SEC-1", "dev", "OPEN"), approved, changes}}, + merged: map[string][]bitbucket.ListedPR{"sec": {listed("sec", "api", 9, "SEC-0", "master", "MERGED")}}, + repos: []string{"api", "web"}, + review: map[string][]bitbucket.ListedPR{"web": {listed("sec", "web", 5, "SEC-7", "dev", "OPEN")}}, + } + src := &BitbucketSource{API: api} + since := time.Date(2026, 10, 24, 12, 0, 0, 0, time.UTC) + + items, warnings, err := src.Discover(context.Background(), Filter{Mine: true, Review: true, MergedSince: since}) + if err != nil || len(warnings) != 0 { + t.Fatalf("err=%v warnings=%v", err, warnings) + } + var got []string + for _, it := range items { + got = append(got, fmt.Sprintf("%s#%d:%s:%s:%s:%d", it.Repo, it.Number, it.State, it.Role, it.Review, it.Approvals)) + } + want := "sec/api#1:open:mine:none:0 sec/api#2:open:mine:approved:2 sec/web#3:open:mine:changes_requested:0 sec/api#9:merged:mine:none:0 sec/web#5:open:review:none:0" + if strings.Join(got, " ") != want { + t.Errorf("items = %s\nwant %s", strings.Join(got, " "), want) + } + if !api.mergedSinceAsked.Equal(since) { + t.Errorf("merged since = %v", api.mergedSinceAsked) + } + if items[0].URL != "https://bitbucket.org/sec/api/pull-requests/1" || items[0].Target != "dev" { + t.Errorf("item = %+v", items[0]) + } +} + +func TestBitbucketSource_ReviewScanIsThrottled(t *testing.T) { + now := time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC) + api := &fakeBitbucket{user: "{me}", workspaces: []string{"sec"}, repos: []string{"api"}, + review: map[string][]bitbucket.ListedPR{"api": {listed("sec", "api", 5, "x", "dev", "OPEN")}}} + src := &BitbucketSource{API: api, Now: func() time.Time { return now }} + + for i := 0; i < 3; i++ { + items, _, _ := src.Discover(context.Background(), Filter{Review: true}) + if len(items) != 1 { + t.Fatalf("refresh %d: the last scan's PRs must still be shown: %d", i, len(items)) + } + now = now.Add(time.Minute) + } + if api.repoScans != 1 { + t.Errorf("repositories scanned %d times in 3 minutes, want once per %s", api.repoScans, DefaultReviewScanEvery) + } + now = now.Add(DefaultReviewScanEvery) + src.Discover(context.Background(), Filter{Review: true}) + if api.repoScans != 2 { + t.Errorf("scans = %d, want a new one after %s", api.repoScans, DefaultReviewScanEvery) + } +} + +func TestBitbucketSource_NeedsTokenOrUser(t *testing.T) { + src := &BitbucketSource{API: &fakeBitbucket{userErr: "401"}} + if _, _, err := src.Discover(context.Background(), Filter{Mine: true}); err == nil || !strings.Contains(err.Error(), "bitbucket_token") { + t.Errorf("err = %v", err) + } + src = &BitbucketSource{API: &fakeBitbucket{}, User: "{me}"} + if _, _, err := src.Discover(context.Background(), Filter{Mine: true}); err == nil || !strings.Contains(err.Error(), "bitbucket_workspaces") { + t.Errorf("err = %v", err) + } +} + +func TestBitbucketSource_Enrich(t *testing.T) { + api := &fakeBitbucket{merge: "dirty", runs: []github.CheckRun{ + {Name: "Pipeline", Status: "completed", Conclusion: "success"}, + {Name: "Jenkins", Status: "in_progress"}, + }} + src := &BitbucketSource{API: api} + it := toItems([]bitbucket.ListedPR{listed("sec", "api", 1, "x", "dev", "OPEN")}, RoleMine)[0] + if err := src.Enrich(context.Background(), &it, true); err != nil { + t.Fatal(err) + } + if it.CI != "pending" || it.CIDone != 1 || it.CITotal != 2 || it.Merge != "conflict" { + t.Errorf("item = %+v", it) + } +} + +// fakeGitHub implements GitHubAPI. +type fakeGitHub struct { + results map[string][]github.SearchResultPR // by role keyword + prs map[int]*github.PullRequest + reviews []github.Review +} + +func (f *fakeGitHub) SearchPullRequests(ctx context.Context, q string) (*github.SearchResult, error) { + // Most specific keyword first: the merged query contains "author:@me" too. + for _, kw := range []string{"author:@me merged:", "review-requested", "author:@me"} { + if prs, ok := f.results[kw]; ok && strings.Contains(q, kw) { + return &github.SearchResult{PRs: prs, Total: len(prs)}, nil + } + } + return &github.SearchResult{}, nil +} +func (f *fakeGitHub) GetPullRequest(ctx context.Context, o, r string, n int) (*github.PullRequest, error) { + if pr, ok := f.prs[n]; ok { + return pr, nil + } + return nil, errors.New("404") +} +func (f *fakeGitHub) GetReviews(ctx context.Context, o, r string, n int) ([]github.Review, error) { + return f.reviews, nil +} +func (f *fakeGitHub) GetCombinedStatus(ctx context.Context, o, r, ref string) (*github.CombinedStatus, error) { + return &github.CombinedStatus{State: "pending"}, nil +} +func (f *fakeGitHub) GetCheckRuns(ctx context.Context, o, r, ref string) ([]github.CheckRun, error) { + return []github.CheckRun{{Name: "Test", Status: "completed", Conclusion: "success"}}, nil +} +func (f *fakeGitHub) RequestsInLastHour() int { return 11 } + +func ghPR(n int, head, base, state string, updated time.Time) *github.PullRequest { + pr := &github.PullRequest{Number: n, Title: "T", State: state, UpdatedAt: updated, MergeableState: "clean"} + pr.Head.SHA, pr.Head.Ref, pr.Base.Ref = "sha", head, base + return pr +} + +func TestGitHubSource(t *testing.T) { + since := time.Date(2026, 10, 24, 0, 0, 0, 0, time.UTC) + api := &fakeGitHub{ + results: map[string][]github.SearchResultPR{ + "author:@me merged:": {{Owner: "o", Repo: "r", Number: 3}, {Owner: "o", Repo: "r", Number: 4}}, + "author:@me": {{Owner: "o", Repo: "r", Number: 1}, {Owner: "o", Repo: "gone", Number: 99}}, + "review-requested": {{Owner: "o", Repo: "r", Number: 2}}, + }, + prs: map[int]*github.PullRequest{ + 1: ghPR(1, "A-1/x", "dev", "open", since), 2: ghPR(2, "B-2/y", "main", "open", since), + 3: ghPR(3, "A-0/z", "main", "closed", since.Add(time.Hour)), + 4: ghPR(4, "old", "main", "closed", since.Add(-time.Hour)), // merged before the window + }, + reviews: []github.Review{ + {State: "APPROVED", User: struct { + Login string `json:"login"` + }{"ana"}}, + }, + } + src := &GitHubSource{API: api} + items, warnings, err := src.Discover(context.Background(), Filter{Mine: true, Review: true, MergedSince: since}) + if err != nil { + t.Fatal(err) + } + var got []string + for _, it := range items { + got = append(got, fmt.Sprintf("#%d:%s:%s:%s->%s:%s", it.Number, it.State, it.Role, it.Source, it.Target, it.Merge)) + } + if want := "#1:open:mine:A-1/x->dev:ready #2:open:review:B-2/y->main:ready #3:merged:mine:A-0/z->main:ready"; strings.Join(got, " ") != want { + t.Errorf("items = %s\nwant %s", strings.Join(got, " "), want) + } + if len(warnings) != 1 || !strings.Contains(warnings[0], "o/gone#99") { + t.Errorf("warnings = %v", warnings) + } + + it := items[0] + if err := src.Enrich(context.Background(), &it, true); err != nil { + t.Fatal(err) + } + if it.CI != "success" || it.Review != "approved" || it.Approvals != 1 || it.CITotal != 1 { + t.Errorf("enriched = %+v", it) + } +} diff --git a/internal/github/client.go b/internal/github/client.go index c084cbe..b1b5f87 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -41,6 +41,35 @@ type Client struct { mu sync.Mutex cache map[string]cachedResponse rateLimit RateLimit + // sent holds when recent requests were made, for RequestsInLastHour. + sent []time.Time +} + +// RequestsInLastHour is how many requests this client sent in the last hour +// (conditional ones included), to show how much of an hourly quota is used. +func (c *Client) RequestsInLastHour() int { + c.mu.Lock() + defer c.mu.Unlock() + c.trimSent(time.Now()) + return len(c.sent) +} + +// countRequest records a request; c.mu must not be held. +func (c *Client) countRequest() { + c.mu.Lock() + defer c.mu.Unlock() + now := time.Now() + c.trimSent(now) + c.sent = append(c.sent, now) +} + +func (c *Client) trimSent(now time.Time) { + cutoff := now.Add(-time.Hour) + i := 0 + for i < len(c.sent) && c.sent[i].Before(cutoff) { + i++ + } + c.sent = c.sent[i:] } type cachedResponse struct { @@ -122,7 +151,11 @@ type PullRequest struct { Merged bool `json:"merged"` Head struct { SHA string `json:"sha"` + Ref string `json:"ref"` // source branch } `json:"head"` + Base struct { + Ref string `json:"ref"` // target branch + } `json:"base"` // UpdatedAt changes on every push (and on comments, labels, ...), so it is an // upper bound for how recently the head commit was pushed. UpdatedAt time.Time `json:"updated_at"` @@ -507,6 +540,7 @@ func (c *Client) getJSON(ctx context.Context, path string, v interface{}) error req.Header.Set("If-None-Match", cached.etag) } + c.countRequest() resp, err := c.HTTPClient.Do(req) if err != nil { return fmt.Errorf("request failed: %w", err)