From 1888c03c7b4b8ce6b043d9c30e980a5ee2e64324 Mon Sep 17 00:00:00 2001 From: Thomas Stromberg Date: Sun, 19 Oct 2025 18:12:10 +0200 Subject: [PATCH] Add Slacker Home page --- cmd/server/main.go | 4 + internal/bot/interfaces.go | 1 + internal/slack/home_handler.go | 147 ++++++++++++++++ internal/slack/manager.go | 29 +++- internal/slack/slack.go | 48 +++++- internal/state/datastore.go | 27 +-- internal/state/store.go | 11 +- pkg/home/fetcher.go | 298 +++++++++++++++++++++++++++++++++ pkg/home/types.go | 60 +++++++ pkg/home/ui.go | 170 +++++++++++++++++++ 10 files changed, 767 insertions(+), 28 deletions(-) create mode 100644 internal/slack/home_handler.go create mode 100644 pkg/home/fetcher.go create mode 100644 pkg/home/types.go create mode 100644 pkg/home/ui.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 090615e..710784f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -235,6 +235,10 @@ func run(ctx context.Context, cancel context.CancelFunc, cfg *config.ServerConfi // Initialize event router for multi-workspace event handling. eventRouter := slack.NewEventRouter(slackManager) + // Initialize home view handler + homeHandler := slack.NewHomeHandler(slackManager, githubManager, configManager, stateStore) + slackManager.SetHomeViewHandler(homeHandler.HandleAppHomeOpened) + // Initialize OAuth handler for Slack app installation. // These credentials are needed for the OAuth flow. slackClientID := os.Getenv("SLACK_CLIENT_ID") diff --git a/internal/bot/interfaces.go b/internal/bot/interfaces.go index b5b0295..b663a18 100644 --- a/internal/bot/interfaces.go +++ b/internal/bot/interfaces.go @@ -19,6 +19,7 @@ type SlackClient interface { IsBotInChannel(ctx context.Context, channelID string) bool BotInfo(ctx context.Context) (*slack.AuthTestResponse, error) WorkspaceInfo(ctx context.Context) (*slack.TeamInfo, error) + PublishHomeView(userID string, blocks []slack.Block) error API() *slack.Client } diff --git a/internal/slack/home_handler.go b/internal/slack/home_handler.go new file mode 100644 index 0000000..a545295 --- /dev/null +++ b/internal/slack/home_handler.go @@ -0,0 +1,147 @@ +package slack + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "github.com/codeGROOVE-dev/slacker/internal/config" + "github.com/codeGROOVE-dev/slacker/internal/github" + "github.com/codeGROOVE-dev/slacker/internal/state" + "github.com/codeGROOVE-dev/slacker/pkg/home" +) + +// HomeHandler handles app_home_opened events for a workspace. +type HomeHandler struct { + slackManager *Manager + githubManager *github.Manager + configManager *config.Manager + stateStore state.Store +} + +// NewHomeHandler creates a new home view handler. +func NewHomeHandler( + slackManager *Manager, + githubManager *github.Manager, + configManager *config.Manager, + stateStore state.Store, +) *HomeHandler { + return &HomeHandler{ + slackManager: slackManager, + githubManager: githubManager, + configManager: configManager, + stateStore: stateStore, + } +} + +// HandleAppHomeOpened updates the app home view when a user opens it. +func (h *HomeHandler) HandleAppHomeOpened(ctx context.Context, teamID, slackUserID string) error { + slog.Debug("handling app home opened", + "team_id", teamID, + "slack_user_id", slackUserID) + + // Get Slack client for this workspace + slackClient, err := h.slackManager.Client(ctx, teamID) + if err != nil { + return fmt.Errorf("failed to get Slack client: %w", err) + } + + // Get Slack user info to extract email + slackUser, err := slackClient.API().GetUserInfo(slackUserID) + if err != nil { + slog.Warn("failed to get Slack user info", "user_id", slackUserID, "error", err) + return h.publishPlaceholderHome(slackClient, slackUserID) + } + + // Extract GitHub username from email (simple heuristic: part before @) + // Works for "username@company.com" -> "username" + email := slackUser.Profile.Email + atIndex := strings.IndexByte(email, '@') + if atIndex <= 0 { + slog.Warn("could not extract GitHub username from Slack email", + "slack_user_id", slackUserID, + "email", email) + return h.publishPlaceholderHome(slackClient, slackUserID) + } + githubUsername := email[:atIndex] + + // Get all orgs for this workspace + workspaceOrgs := h.workspaceOrgs(teamID) + if len(workspaceOrgs) == 0 { + slog.Warn("no workspace orgs found", "team_id", teamID) + return h.publishPlaceholderHome(slackClient, slackUserID) + } + + // Get GitHub client for first org (they all share the same app) + githubClient, ok := h.githubManager.ClientForOrg(workspaceOrgs[0]) + if !ok { + return fmt.Errorf("no GitHub client for org: %s", workspaceOrgs[0]) + } + + // Create fetcher and fetch dashboard + fetcher := home.NewFetcher( + githubClient.Client(), + h.stateStore, + githubClient.InstallationToken(ctx), + "ready-to-review[bot]", + ) + + dashboard, err := fetcher.FetchDashboard(ctx, githubUsername, workspaceOrgs) + if err != nil { + slog.Error("failed to fetch dashboard", + "github_user", githubUsername, + "error", err) + return h.publishPlaceholderHome(slackClient, slackUserID) + } + + // Build Block Kit UI - use first org as primary + blocks := home.BuildBlocks(dashboard, workspaceOrgs[0]) + + // Publish to Slack + if err := slackClient.PublishHomeView(slackUserID, blocks); err != nil { + return fmt.Errorf("failed to publish home view: %w", err) + } + + slog.Info("published home view", + "slack_user_id", slackUserID, + "github_user", githubUsername, + "incoming_prs", len(dashboard.IncomingPRs), + "outgoing_prs", len(dashboard.OutgoingPRs), + "workspace_orgs", len(workspaceOrgs)) + + return nil +} + +// workspaceOrgs returns all GitHub orgs configured for this Slack workspace. +func (h *HomeHandler) workspaceOrgs(teamID string) []string { + allOrgs := h.githubManager.AllOrgs() + var workspaceOrgs []string + + for _, org := range allOrgs { + cfg, exists := h.configManager.Config(org) + if !exists { + continue + } + + // Check if this org is configured for this workspace + if cfg.Global.TeamID == teamID { + workspaceOrgs = append(workspaceOrgs, org) + } + } + + return workspaceOrgs +} + +// publishPlaceholderHome publishes a simple placeholder home view. +func (*HomeHandler) publishPlaceholderHome(slackClient *Client, slackUserID string) error { + slog.Debug("publishing placeholder home", "user_id", slackUserID) + + blocks := home.BuildBlocks(&home.Dashboard{ + IncomingPRs: nil, + OutgoingPRs: nil, + WorkspaceOrgs: []string{"your-org"}, + }, "your-org") + + return slackClient.PublishHomeView(slackUserID, blocks) +} diff --git a/internal/slack/manager.go b/internal/slack/manager.go index a0da398..5de3006 100644 --- a/internal/slack/manager.go +++ b/internal/slack/manager.go @@ -20,10 +20,11 @@ type WorkspaceMetadata struct { // Manager manages Slack clients for multiple workspaces. type Manager struct { - clients map[string]*Client // team_id -> client - metadata map[string]*WorkspaceMetadata - signingSecret string - mu sync.RWMutex + clients map[string]*Client // team_id -> client + metadata map[string]*WorkspaceMetadata + signingSecret string + homeViewHandler func(ctx context.Context, teamID, userID string) error // Global home view handler + mu sync.RWMutex } // NewManager creates a new Slack client manager. @@ -81,6 +82,12 @@ func (m *Manager) Client(ctx context.Context, teamID string) (*Client, error) { // Create client client = New(token, m.signingSecret) + client.SetTeamID(teamID) + + // Set home view handler if configured + if m.homeViewHandler != nil { + client.SetHomeViewHandler(m.homeViewHandler) + } // Cache it m.mu.Lock() @@ -96,6 +103,20 @@ func (m *Manager) Client(ctx context.Context, teamID string) (*Client, error) { return client, nil } +// SetHomeViewHandler sets the home view handler on all current and future clients. +func (m *Manager) SetHomeViewHandler(handler func(ctx context.Context, teamID, userID string) error) { + m.mu.Lock() + defer m.mu.Unlock() + + // Store for future clients + m.homeViewHandler = handler + + // Set on all existing clients + for _, client := range m.clients { + client.SetHomeViewHandler(handler) + } +} + // StoreWorkspace stores a workspace's token and metadata in GSM. func (m *Manager) StoreWorkspace(ctx context.Context, metadata *WorkspaceMetadata, token string) error { slog.Info("storing workspace token and metadata in GSM", diff --git a/internal/slack/slack.go b/internal/slack/slack.go index a9156db..84777eb 100644 --- a/internal/slack/slack.go +++ b/internal/slack/slack.go @@ -45,9 +45,12 @@ type apiCache struct { // Client wraps the Slack API client with caching. type Client struct { - api *slack.Client - cache *apiCache - signingSecret string + api *slack.Client + cache *apiCache + signingSecret string + teamID string // Workspace team ID + homeViewHandler func(ctx context.Context, teamID, userID string) error // Callback for app_home_opened events + homeViewHandlerMu sync.RWMutex } // set stores a value in the cache with TTL. @@ -115,6 +118,18 @@ func New(token, signingSecret string) *Client { } } +// SetHomeViewHandler registers a callback for app_home_opened events. +func (c *Client) SetHomeViewHandler(handler func(ctx context.Context, teamID, userID string) error) { + c.homeViewHandlerMu.Lock() + defer c.homeViewHandlerMu.Unlock() + c.homeViewHandler = handler +} + +// SetTeamID sets the team ID for this client. +func (c *Client) SetTeamID(teamID string) { + c.teamID = teamID +} + // WorkspaceInfo returns information about the current workspace (cached for 1 hour). func (c *Client) WorkspaceInfo(ctx context.Context) (*slack.TeamInfo, error) { cacheKey := "team_info" @@ -775,10 +790,29 @@ func (c *Client) EventsHandler(writer http.ResponseWriter, r *http.Request) { // Handle app mentions if needed. slog.Debug("received app mention", "event", evt) case *slackevents.AppHomeOpenedEvent: - // Update app home when user opens it. - // In a full implementation, this would update the home tab. - // For now, just log. - slog.Debug("would update app home for user", "user", evt.User) + // Update app home when user opens it + slog.Debug("app home opened", "user", evt.User) + + // Call registered home view handler if present + c.homeViewHandlerMu.RLock() + handler := c.homeViewHandler + c.homeViewHandlerMu.RUnlock() + + if handler != nil { + go func(teamID, userID string) { + homeCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := handler(homeCtx, teamID, userID); err != nil { + slog.Error("home view handler failed", + "team_id", teamID, + "user", userID, + "error", err) + } + }(c.teamID, evt.User) + } else { + slog.Debug("no home view handler registered", "user", evt.User) + } case *slackevents.MemberJoinedChannelEvent: // Bot was added to a channel - invalidate cache slog.Info("bot joined channel - invalidating cache", diff --git a/internal/state/datastore.go b/internal/state/datastore.go index e6a47cd..b771d04 100644 --- a/internal/state/datastore.go +++ b/internal/state/datastore.go @@ -29,10 +29,11 @@ const ( // Thread entity for Datastore. type threadEntity struct { - ThreadTS string `datastore:"thread_ts"` - ChannelID string `datastore:"channel_id"` - MessageText string `datastore:"message_text,noindex"` - UpdatedAt time.Time `datastore:"updated_at"` + ThreadTS string `datastore:"thread_ts"` + ChannelID string `datastore:"channel_id"` + MessageText string `datastore:"message_text,noindex"` + UpdatedAt time.Time `datastore:"updated_at"` + LastEventTime time.Time `datastore:"last_event_time"` } // DM tracking entity. @@ -155,10 +156,11 @@ func (s *DatastoreStore) GetThread(owner, repo string, number int, channelID str // Found in Datastore - update JSON cache and return result := ThreadInfo{ - ThreadTS: entity.ThreadTS, - ChannelID: entity.ChannelID, - MessageText: entity.MessageText, - UpdatedAt: entity.UpdatedAt, + ThreadTS: entity.ThreadTS, + ChannelID: entity.ChannelID, + MessageText: entity.MessageText, + UpdatedAt: entity.UpdatedAt, + LastEventTime: entity.LastEventTime, } // Async update JSON cache (don't wait) @@ -192,10 +194,11 @@ func (s *DatastoreStore) SaveThread(owner, repo string, number int, channelID st dsKey := datastore.NameKey(kindThread, key, nil) entity := &threadEntity{ - ThreadTS: info.ThreadTS, - ChannelID: info.ChannelID, - MessageText: info.MessageText, - UpdatedAt: time.Now(), + ThreadTS: info.ThreadTS, + ChannelID: info.ChannelID, + MessageText: info.MessageText, + UpdatedAt: time.Now(), + LastEventTime: info.LastEventTime, } if _, err := s.ds.Put(ctx, dsKey, entity); err != nil { diff --git a/internal/state/store.go b/internal/state/store.go index bc7bd06..2f7772e 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -7,11 +7,12 @@ import ( // ThreadInfo stores information about a Slack thread for a PR. type ThreadInfo struct { - UpdatedAt time.Time `json:"updated_at"` - ThreadTS string `json:"thread_ts"` - ChannelID string `json:"channel_id"` - LastState string `json:"last_state"` - MessageText string `json:"message_text"` + UpdatedAt time.Time `json:"updated_at"` + LastEventTime time.Time `json:"last_event_time"` // Last sprinkler event timestamp for turnclient cache optimization + ThreadTS string `json:"thread_ts"` + ChannelID string `json:"channel_id"` + LastState string `json:"last_state"` + MessageText string `json:"message_text"` } // Store provides persistent storage for bot state. diff --git a/pkg/home/fetcher.go b/pkg/home/fetcher.go new file mode 100644 index 0000000..90a61de --- /dev/null +++ b/pkg/home/fetcher.go @@ -0,0 +1,298 @@ +package home + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/codeGROOVE-dev/retry" + "github.com/codeGROOVE-dev/slacker/internal/state" + "github.com/codeGROOVE-dev/turnclient/pkg/turn" + "github.com/google/go-github/v50/github" +) + +const ( + stalePRThreshold = 90 * 24 * time.Hour // 90 days +) + +// Fetcher retrieves and enriches PRs for the home dashboard. +type Fetcher struct { + githubClient *github.Client + stateStore state.Store + githubToken string + botUsername string +} + +// NewFetcher creates a new PR fetcher. +func NewFetcher(githubClient *github.Client, stateStore state.Store, githubToken, botUsername string) *Fetcher { + return &Fetcher{ + githubClient: githubClient, + stateStore: stateStore, + githubToken: githubToken, + botUsername: botUsername, + } +} + +// FetchDashboard fetches all PRs for a GitHub user across workspace organizations. +// Degrades gracefully - returns partial data on errors rather than failing completely. +func (f *Fetcher) FetchDashboard(ctx context.Context, githubUsername string, workspaceOrgs []string) (*Dashboard, error) { + slog.Debug("fetching dashboard for user", + "github_user", githubUsername, + "workspace_orgs", workspaceOrgs) + + dashboard := &Dashboard{ + WorkspaceOrgs: workspaceOrgs, + } + + // Fetch all open PRs involving this user across workspace orgs + // Continues even if some orgs fail - returns what we can get + incoming, outgoing := f.fetchUserPRs(ctx, githubUsername, workspaceOrgs) + + slog.Debug("fetched PRs from GitHub", + "github_user", githubUsername, + "incoming_count", len(incoming), + "outgoing_count", len(outgoing)) + + // Enrich with turnclient data + // Degrades gracefully - returns basic PR data if turnclient fails + dashboard.IncomingPRs = f.enrichPRs(ctx, incoming, githubUsername, true) + dashboard.OutgoingPRs = f.enrichPRs(ctx, outgoing, githubUsername, false) + + slog.Debug("enriched PRs with turnclient", + "github_user", githubUsername, + "incoming_enriched", len(dashboard.IncomingPRs), + "outgoing_enriched", len(dashboard.OutgoingPRs)) + + // Sort: blocked first, then by recency + sortPRs(dashboard.IncomingPRs) + sortPRs(dashboard.OutgoingPRs) + + return dashboard, nil +} + +// fetchUserPRs fetches incoming and outgoing PRs for a user. +func (f *Fetcher) fetchUserPRs(ctx context.Context, githubUsername string, workspaceOrgs []string) (incoming, outgoing []PR) { + // Validate inputs to prevent injection in GitHub search queries + if githubUsername == "" || strings.ContainsAny(githubUsername, " \t\n\"'") { + slog.Warn("invalid GitHub username", "username", githubUsername) + return nil, nil + } + + staleThreshold := time.Now().Add(-stalePRThreshold) + + // Search for PRs across all workspace orgs + for _, org := range workspaceOrgs { + // Validate org name + if org == "" || strings.ContainsAny(org, " \t\n\"'") { + slog.Warn("invalid org name, skipping", "org", org) + continue + } + + // Fetch authored PRs (outgoing) + authorQuery := fmt.Sprintf("is:pr is:open author:%s org:%s", githubUsername, org) + authoredPRs, err := f.searchPRs(ctx, authorQuery) + if err != nil { + slog.Warn("failed to search authored PRs", "org", org, "error", err) + continue + } + + for i := range authoredPRs { + if authoredPRs[i].UpdatedAt.Before(staleThreshold) { + continue // Skip stale PRs + } + outgoing = append(outgoing, authoredPRs[i]) + } + + // Fetch review-requested PRs (incoming) + reviewQuery := fmt.Sprintf("is:pr is:open review-requested:%s org:%s", githubUsername, org) + reviewPRs, err := f.searchPRs(ctx, reviewQuery) + if err != nil { + slog.Warn("failed to search review-requested PRs", "org", org, "error", err) + continue + } + + for i := range reviewPRs { + if reviewPRs[i].UpdatedAt.Before(staleThreshold) { + continue // Skip stale PRs + } + incoming = append(incoming, reviewPRs[i]) + } + } + + return incoming, outgoing +} + +// searchPRs executes a GitHub search query and returns matching PRs. +func (f *Fetcher) searchPRs(ctx context.Context, query string) ([]PR, error) { + opts := &github.SearchOptions{ + Sort: "updated", + Order: "desc", + ListOptions: github.ListOptions{ + PerPage: 100, + }, + } + + var result *github.IssuesSearchResult + err := retry.Do( + func() error { + var resp *github.Response + var err error + result, resp, err = f.githubClient.Search.Issues(ctx, query, opts) + if err != nil { + // Check if it's a rate limit error + if resp != nil && resp.StatusCode == http.StatusForbidden { + slog.Warn("GitHub API rate limited, will retry", "query", query) + return err // Retry on rate limit + } + // Other errors are unrecoverable + return retry.Unrecoverable(err) + } + return nil + }, + retry.Attempts(5), + retry.Delay(time.Second), + retry.MaxDelay(2*time.Minute), + retry.DelayType(retry.BackOffDelay), + retry.MaxJitter(time.Second), + retry.Context(ctx), + ) + if err != nil { + return nil, fmt.Errorf("GitHub search failed after retries: %w", err) + } + + var prs []PR + for _, issue := range result.Issues { + if issue.PullRequestLinks == nil { + continue // Not a PR + } + + // Extract owner/repo from repository URL + // Example: "https://api.github.com/repos/owner/repo" → "owner/repo" + repoURL := issue.GetRepositoryURL() + parts := strings.Split(repoURL, "/repos/") + if len(parts) != 2 { + continue // Skip if URL format unexpected + } + repo := parts[1] + + pr := PR{ + Number: issue.GetNumber(), + Title: issue.GetTitle(), + URL: issue.GetHTMLURL(), + Repository: repo, + Author: issue.GetUser().GetLogin(), + UpdatedAt: issue.GetUpdatedAt().Time, + IsDraft: false, // Draft status is on PullRequest, not Issue + } + + // Check state store for last event time + // Split "owner/repo" into owner and repo + repoParts := strings.SplitN(repo, "/", 2) + if len(repoParts) != 2 { + continue // Skip malformed repo + } + owner, repoName := repoParts[0], repoParts[1] + if threadInfo, exists := f.stateStore.GetThread(owner, repoName, pr.Number, ""); exists { + pr.LastEventTime = threadInfo.LastEventTime + } + + // If no event time, use UpdatedAt + if pr.LastEventTime.IsZero() { + pr.LastEventTime = pr.UpdatedAt + } + + prs = append(prs, pr) + } + + return prs, nil +} + +// enrichPRs enriches PRs with turnclient analysis. +func (f *Fetcher) enrichPRs(ctx context.Context, prs []PR, githubUsername string, incoming bool) []PR { + enriched := make([]PR, 0, len(prs)) + + turnClient, err := turn.NewDefaultClient() + if err != nil { + slog.Warn("failed to create turnclient", "error", err) + return prs // Return unenriched + } + turnClient.SetAuthToken(f.githubToken) + + for i := range prs { + pr := prs[i] + + // Call turnclient with last event time for cache optimization, with retry + var checkResult *turn.CheckResponse + err := retry.Do( + func() error { + var err error + checkResult, err = turnClient.Check(ctx, pr.URL, f.botUsername, pr.LastEventTime) + return err + }, + retry.Attempts(3), // Fewer attempts for per-PR enrichment + retry.Delay(500*time.Millisecond), + retry.MaxDelay(30*time.Second), + retry.DelayType(retry.BackOffDelay), + retry.MaxJitter(time.Second), + retry.Context(ctx), + ) + if err != nil { + slog.Debug("turnclient check failed after retries, using basic PR data", + "pr", pr.URL, + "error", err) + enriched = append(enriched, pr) + continue + } + + // Extract action for this user + if action, exists := checkResult.Analysis.NextAction[githubUsername]; exists { + pr.ActionReason = action.Reason + pr.ActionKind = string(action.Kind) + + if incoming { + pr.NeedsReview = action.Critical + } else { + pr.IsBlocked = action.Critical + } + } + + // Extract test state from Analysis + checks := checkResult.Analysis.Checks + switch { + case checks.Failing > 0: + pr.TestState = "failing" + case checks.Pending > 0 || checks.Waiting > 0: + pr.TestState = "running" + case checks.Passing > 0: + pr.TestState = "passing" + default: + // No test state information available + } + + enriched = append(enriched, pr) + } + + return enriched +} + +// sortPRs sorts PRs with blocked first, then by recency. +func sortPRs(prs []PR) { + // Simple bubble sort - fine for small lists + for i := 0; i < len(prs); i++ { + for j := i + 1; j < len(prs); j++ { + iBlocked := prs[i].IsBlocked || prs[i].NeedsReview + jBlocked := prs[j].IsBlocked || prs[j].NeedsReview + + // Determine if we should swap: blocked items first, then by recency + shouldSwap := (!iBlocked && jBlocked) || + (iBlocked == jBlocked && prs[i].UpdatedAt.Before(prs[j].UpdatedAt)) + + if shouldSwap { + prs[i], prs[j] = prs[j], prs[i] + } + } + } +} diff --git a/pkg/home/types.go b/pkg/home/types.go new file mode 100644 index 0000000..19efcd0 --- /dev/null +++ b/pkg/home/types.go @@ -0,0 +1,60 @@ +// Package home implements the Slack app home dashboard. +package home + +import ( + "time" +) + +// PR represents a pull request for display in the home dashboard. +type PR struct { + UpdatedAt time.Time // Last time PR metadata was updated + LastEventTime time.Time // Last sprinkler event for turnclient cache optimization + Title string + URL string + Repository string // "owner/repo" format + Author string // GitHub username + ActionReason string // Human-readable reason from turnclient + ActionKind string // Action type: review, merge, fix_tests, etc. + TestState string // Test state: running, passing, failing + Number int + IsDraft bool + IsBlocked bool // Outgoing PRs - blocked on author + NeedsReview bool // Incoming PRs - needs user's review +} + +// Dashboard contains all data needed to render the home tab. +type Dashboard struct { + IncomingPRs []PR + OutgoingPRs []PR + WorkspaceOrgs []string // Organizations tracked by this workspace +} + +// PRCounts summarizes PR counts for display. +type PRCounts struct { + IncomingTotal int + IncomingBlocked int + OutgoingTotal int + OutgoingBlocked int +} + +// Counts returns summary counts for the dashboard. +func (d *Dashboard) Counts() PRCounts { + var counts PRCounts + + counts.IncomingTotal = len(d.IncomingPRs) + counts.OutgoingTotal = len(d.OutgoingPRs) + + for i := range d.IncomingPRs { + if d.IncomingPRs[i].NeedsReview { + counts.IncomingBlocked++ + } + } + + for i := range d.OutgoingPRs { + if d.OutgoingPRs[i].IsBlocked { + counts.OutgoingBlocked++ + } + } + + return counts +} diff --git a/pkg/home/ui.go b/pkg/home/ui.go new file mode 100644 index 0000000..5709490 --- /dev/null +++ b/pkg/home/ui.go @@ -0,0 +1,170 @@ +package home + +import ( + "fmt" + "strings" + "time" + + "github.com/slack-go/slack" +) + +// BuildBlocks creates Slack Block Kit UI for the home dashboard. +// Design: Craigslist meets Apple - minimal, clean, functional. +func BuildBlocks(dashboard *Dashboard, primaryOrg string) []slack.Block { + var blocks []slack.Block + + // Header + blocks = append(blocks, + slack.NewHeaderBlock( + slack.NewTextBlockObject("plain_text", "Ready to Review", false, false), + ), + ) + + counts := dashboard.Counts() + + // Incoming section + blocks = append(blocks, + slack.NewDividerBlock(), + slack.NewSectionBlock( + slack.NewTextBlockObject("mrkdwn", + fmt.Sprintf("*INCOMING* (%d blocked, %d total)", + counts.IncomingBlocked, + counts.IncomingTotal), + false, + false, + ), + nil, + nil, + ), + ) + + if len(dashboard.IncomingPRs) == 0 { + blocks = append(blocks, + slack.NewSectionBlock( + slack.NewTextBlockObject("mrkdwn", "_No incoming PRs_", false, false), + nil, + nil, + ), + ) + } else { + for i := range dashboard.IncomingPRs { + blocks = append(blocks, formatPRBlock(&dashboard.IncomingPRs[i])) + } + } + + // Outgoing section + blocks = append(blocks, + slack.NewDividerBlock(), + slack.NewSectionBlock( + slack.NewTextBlockObject("mrkdwn", + fmt.Sprintf("*OUTGOING* (%d blocked, %d total)", + counts.OutgoingBlocked, + counts.OutgoingTotal), + false, + false, + ), + nil, + nil, + ), + ) + + if len(dashboard.OutgoingPRs) == 0 { + blocks = append(blocks, + slack.NewSectionBlock( + slack.NewTextBlockObject("mrkdwn", "_No outgoing PRs_", false, false), + nil, + nil, + ), + ) + } else { + for i := range dashboard.OutgoingPRs { + blocks = append(blocks, formatPRBlock(&dashboard.OutgoingPRs[i])) + } + } + + // Footer with link to comprehensive web dashboard + blocks = append(blocks, + slack.NewDividerBlock(), + slack.NewContextBlock( + "", + slack.NewTextBlockObject("mrkdwn", + fmt.Sprintf("For a more comprehensive view, visit <%s|%s.ready-to-review.dev>", + fmt.Sprintf("https://%s.ready-to-review.dev", primaryOrg), + primaryOrg, + ), + false, + false, + ), + ), + ) + + return blocks +} + +// formatPRBlock formats a single PR as a Slack block. +func formatPRBlock(pr *PR) slack.Block { + // Determine emoji prefix based on blocking status + emoji := "•" + if pr.IsBlocked || pr.NeedsReview { + emoji = "■" + } + + // Build PR line: ■ repo#number — action • age + // Extract repo name from "owner/repo" + repoParts := strings.SplitN(pr.Repository, "/", 2) + repo := pr.Repository + if len(repoParts) == 2 { + repo = repoParts[1] + } + prRef := fmt.Sprintf("%s#%d", repo, pr.Number) + + line := fmt.Sprintf("%s <%s|%s>", emoji, pr.URL, prRef) + + // Add action kind if present + if pr.ActionKind != "" { + actionDisplay := strings.ReplaceAll(pr.ActionKind, "_", " ") + line = fmt.Sprintf("%s — %s", line, actionDisplay) + } + + // Add age + age := formatAge(pr.UpdatedAt) + line = fmt.Sprintf("%s • %s", line, age) + + // Title as secondary line (truncated if too long) + title := pr.Title + if len(title) > 100 { + title = title[:97] + "..." + } + + text := fmt.Sprintf("%s\n_%s_", line, title) + + return slack.NewSectionBlock( + slack.NewTextBlockObject("mrkdwn", text, false, false), + nil, + nil, + ) +} + +// formatAge formats a timestamp as human-readable age. +// Matches goose's format: 30m, 5h, 12d, 3mo, 2024. +func formatAge(t time.Time) string { + age := time.Since(t) + + if age < time.Hour { + return fmt.Sprintf("%dm", int(age.Minutes())) + } + + if age < 24*time.Hour { + return fmt.Sprintf("%dh", int(age.Hours())) + } + + if age < 30*24*time.Hour { + return fmt.Sprintf("%dd", int(age.Hours()/24)) + } + + if age < 365*24*time.Hour { + return fmt.Sprintf("%dmo", int(age.Hours()/(24*30))) + } + + return t.Format("2006") +}