diff --git a/cmd/server/main.go b/cmd/server/main.go index a5bc44d..4ff284d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -325,7 +325,7 @@ func run(ctx context.Context, cancel context.CancelFunc, cfg *config.ServerConfi // Slack endpoints - routed to workspace-specific clients router.HandleFunc("/slack/events", eventRouter.HandleEvents).Methods("POST") - router.HandleFunc("/slack/interactions", eventRouter.HandleInteractions).Methods("POST") + router.HandleFunc("/slack/interactive-endpoint", eventRouter.HandleInteractions).Methods("POST") router.HandleFunc("/slack/slash", eventRouter.HandleSlashCommand).Methods("POST") // Determine port. diff --git a/internal/bot/interfaces.go b/internal/bot/interfaces.go index b663a18..197c67e 100644 --- a/internal/bot/interfaces.go +++ b/internal/bot/interfaces.go @@ -19,7 +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 + PublishHomeView(ctx context.Context, userID string, blocks []slack.Block) error API() *slack.Client } diff --git a/internal/slack/events_router.go b/internal/slack/events_router.go index c899409..d11e2de 100644 --- a/internal/slack/events_router.go +++ b/internal/slack/events_router.go @@ -5,6 +5,7 @@ import ( "io" "log/slog" "net/http" + "net/url" "github.com/slack-go/slack/slackevents" ) @@ -87,7 +88,11 @@ func (er *EventRouter) HandleEvents(w http.ResponseWriter, req *http.Request) { "team_id", teamID, "event_type", eventWrapper.Type, "remote_addr", req.RemoteAddr, - "user_agent", req.Header.Get("User-Agent")) + "user_agent", req.Header.Get("User-Agent"), + "signature_present", signature != "", + "timestamp", timestamp, + "body_size", len(body), + "response_status", http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized) return } @@ -112,12 +117,31 @@ func (er *EventRouter) HandleInteractions(w http.ResponseWriter, req *http.Reque return } + // Log raw body for debugging (truncate if too large) + bodyPreview := string(body) + if len(bodyPreview) > 500 { + bodyPreview = bodyPreview[:500] + "... (truncated)" + } + slog.Debug("received interaction request", + "body_size", len(body), + "raw_body", bodyPreview, + "remote_addr", req.RemoteAddr) + // Parse payload to extract team_id FIRST (before signature verification) // Interactions come as form-encoded with a "payload" field - payload := req.FormValue("payload") + // We must parse from body bytes since body was already read + values, err := url.ParseQuery(string(body)) + if err != nil { + slog.Error("failed to parse form data", "error", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + payload := values.Get("payload") if payload == "" { - // Try reading from body - payload = string(body) + slog.Error("interaction missing payload field") + w.WriteHeader(http.StatusBadRequest) + return } var interaction struct { @@ -155,12 +179,18 @@ func (er *EventRouter) HandleInteractions(w http.ResponseWriter, req *http.Reque slog.Warn("interaction signature verification failed - possible attack", "team_id", teamID, "remote_addr", req.RemoteAddr, - "user_agent", req.Header.Get("User-Agent")) + "user_agent", req.Header.Get("User-Agent"), + "signature_present", signature != "", + "timestamp", timestamp, + "body_size", len(body), + "response_status", http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized) return } - slog.Debug("routing interaction to workspace", "team_id", teamID) + slog.Debug("routing interaction to workspace", + "team_id", teamID, + "body_size", len(body)) // Forward to the workspace-specific client's interaction handler req.Body = io.NopCloser(&readerWrapper{data: body}) diff --git a/internal/slack/home_handler.go b/internal/slack/home_handler.go index 65e44e8..3c0bb21 100644 --- a/internal/slack/home_handler.go +++ b/internal/slack/home_handler.go @@ -2,6 +2,7 @@ package slack import ( "context" + "errors" "fmt" "log/slog" "strings" @@ -37,10 +38,38 @@ func NewHomeHandler( // 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", + slog.Info("handling app home opened - fetching fresh data", "team_id", teamID, "slack_user_id", slackUserID) + // Try up to 2 times - first with cached client, second with fresh client after invalid_auth + for attempt := 0; attempt < 2; attempt++ { + if attempt > 0 { + slog.Info("retrying home view after invalid_auth", "team_id", teamID, "attempt", attempt+1) + } + + err := h.tryHandleAppHomeOpened(ctx, teamID, slackUserID) + if err == nil { + return nil + } + + // If invalid_auth and first attempt, invalidate cache and retry + if strings.Contains(err.Error(), "invalid_auth") && attempt == 0 { + slog.Warn("invalid_auth detected - invalidating cache and retrying", + "team_id", teamID) + h.slackManager.InvalidateCache(teamID) + continue + } + + // Other errors or second attempt - return immediately + return err + } + + return errors.New("failed after retries") +} + +// tryHandleAppHomeOpened attempts to handle app home opened event. +func (h *HomeHandler) tryHandleAppHomeOpened(ctx context.Context, teamID, slackUserID string) error { // Get Slack client for this workspace slackClient, err := h.slackManager.Client(ctx, teamID) if err != nil { @@ -50,8 +79,12 @@ func (h *HomeHandler) HandleAppHomeOpened(ctx context.Context, teamID, slackUser // Get Slack user info to extract email slackUser, err := slackClient.API().GetUserInfo(slackUserID) if err != nil { + // Don't mask invalid_auth errors - let them propagate for retry logic + if strings.Contains(err.Error(), "invalid_auth") { + return fmt.Errorf("failed to get Slack user info: %w", err) + } slog.Warn("failed to get Slack user info", "user_id", slackUserID, "error", err) - return h.publishPlaceholderHome(slackClient, slackUserID) + return h.publishPlaceholderHome(ctx, slackClient, slackUserID) } // Extract GitHub username from email (simple heuristic: part before @) @@ -62,7 +95,7 @@ func (h *HomeHandler) HandleAppHomeOpened(ctx context.Context, teamID, slackUser slog.Warn("could not extract GitHub username from Slack email", "slack_user_id", slackUserID, "email", email) - return h.publishPlaceholderHome(slackClient, slackUserID) + return h.publishPlaceholderHome(ctx, slackClient, slackUserID) } githubUsername := email[:atIndex] @@ -70,7 +103,7 @@ func (h *HomeHandler) HandleAppHomeOpened(ctx context.Context, teamID, slackUser workspaceOrgs := h.workspaceOrgs(teamID) if len(workspaceOrgs) == 0 { slog.Warn("no workspace orgs found", "team_id", teamID) - return h.publishPlaceholderHome(slackClient, slackUserID) + return h.publishPlaceholderHome(ctx, slackClient, slackUserID) } // Get GitHub client for first org (they all share the same app) @@ -92,7 +125,7 @@ func (h *HomeHandler) HandleAppHomeOpened(ctx context.Context, teamID, slackUser slog.Error("failed to fetch dashboard", "github_user", githubUsername, "error", err) - return h.publishPlaceholderHome(slackClient, slackUserID) + return h.publishPlaceholderHome(ctx, slackClient, slackUserID) } // Add workspace orgs to dashboard for UI display @@ -102,15 +135,17 @@ func (h *HomeHandler) HandleAppHomeOpened(ctx context.Context, teamID, slackUser blocks := home.BuildBlocks(dashboard, workspaceOrgs[0]) // Publish to Slack - if err := slackClient.PublishHomeView(slackUserID, blocks); err != nil { + if err := slackClient.PublishHomeView(ctx, slackUserID, blocks); err != nil { return fmt.Errorf("failed to publish home view: %w", err) } - slog.Info("published home view", + slog.Info("published home view with fresh data", "slack_user_id", slackUserID, "github_user", githubUsername, "incoming_prs", len(dashboard.IncomingPRs), + "incoming_blocked", dashboard.Counts().IncomingBlocked, "outgoing_prs", len(dashboard.OutgoingPRs), + "outgoing_blocked", dashboard.Counts().OutgoingBlocked, "workspace_orgs", len(workspaceOrgs)) return nil @@ -137,7 +172,7 @@ func (h *HomeHandler) workspaceOrgs(teamID string) []string { } // publishPlaceholderHome publishes a simple placeholder home view. -func (*HomeHandler) publishPlaceholderHome(slackClient *Client, slackUserID string) error { +func (*HomeHandler) publishPlaceholderHome(ctx context.Context, slackClient *Client, slackUserID string) error { slog.Debug("publishing placeholder home", "user_id", slackUserID) blocks := home.BuildBlocks(&home.Dashboard{ @@ -146,5 +181,5 @@ func (*HomeHandler) publishPlaceholderHome(slackClient *Client, slackUserID stri WorkspaceOrgs: []string{"your-org"}, }, "your-org") - return slackClient.PublishHomeView(slackUserID, blocks) + return slackClient.PublishHomeView(ctx, slackUserID, blocks) } diff --git a/internal/slack/manager.go b/internal/slack/manager.go index 6799d2a..4bb4000 100644 --- a/internal/slack/manager.go +++ b/internal/slack/manager.go @@ -103,6 +103,7 @@ func (m *Manager) Client(ctx context.Context, teamID string) (*Client, error) { // Create client client = New(token, m.signingSecret) client.SetTeamID(teamID) + client.SetManager(m) // Set manager reference for cache invalidation // Set home view handler if configured if m.homeViewHandler != nil { diff --git a/internal/slack/slack.go b/internal/slack/slack.go index 640a404..ad782f4 100644 --- a/internal/slack/slack.go +++ b/internal/slack/slack.go @@ -50,6 +50,7 @@ type Client struct { cache *apiCache signingSecret string teamID string // Workspace team ID + manager *Manager // Reference to manager for cache invalidation homeViewHandler func(ctx context.Context, teamID, userID string) error // Callback for app_home_opened events homeViewHandlerMu sync.RWMutex stateStore StateStore // State store for DM message tracking @@ -140,6 +141,21 @@ func (c *Client) SetStateStore(store StateStore) { c.stateStore = store } +// SetManager sets the manager reference for cache invalidation. +func (c *Client) SetManager(manager *Manager) { + c.manager = manager +} + +// invalidateWorkspaceCache invalidates this workspace's client in the manager cache. +// This forces a fresh token to be fetched from GSM on next access. +func (c *Client) invalidateWorkspaceCache() { + if c.manager != nil && c.teamID != "" { + c.manager.InvalidateCache(c.teamID) + slog.Info("invalidated workspace cache due to auth event", + "team_id", c.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" @@ -878,7 +894,11 @@ func (c *Client) EventsHandler(writer http.ResponseWriter, r *http.Request) { slog.Debug("received app mention", "event", evt) case *slackevents.AppHomeOpenedEvent: // Update app home when user opens it - slog.Debug("app home opened", "user", evt.User) + slog.Info("app home opened - rendering fresh dashboard", + "user_id", evt.User, + "team_id", c.teamID, + "tab", evt.Tab, + "trigger", "slack_event") // Call registered home view handler if present c.homeViewHandlerMu.RLock() @@ -896,6 +916,11 @@ func (c *Client) EventsHandler(writer http.ResponseWriter, r *http.Request) { "team_id", teamID, "user", userID, "error", err) + } else { + slog.Info("successfully rendered home view", + "team_id", teamID, + "user", userID, + "trigger", "slack_event") } }(c.teamID, evt.User) } else { @@ -914,6 +939,16 @@ func (c *Client) EventsHandler(writer http.ResponseWriter, r *http.Request) { "channel_id", evt.Channel, "user_id", evt.User) c.invalidateChannelCache(evt.Channel) + case *slackevents.TokensRevokedEvent: + // Tokens revoked - invalidate workspace client cache to force refresh + slog.Warn("tokens revoked event received - invalidating workspace cache", + "team_id", c.teamID) + c.invalidateWorkspaceCache() + case *slackevents.AppUninstalledEvent: + // App uninstalled - invalidate workspace client cache + slog.Warn("app uninstalled event received", + "team_id", c.teamID) + c.invalidateWorkspaceCache() } } @@ -925,23 +960,42 @@ func (c *Client) InteractionsHandler(writer http.ResponseWriter, r *http.Request // Parse the payload. payload := r.FormValue("payload") if payload == "" { + slog.Error("interaction missing payload", + "remote_addr", r.RemoteAddr, + "user_agent", r.Header.Get("User-Agent")) writer.WriteHeader(http.StatusBadRequest) return } var interaction slack.InteractionCallback if err := json.Unmarshal([]byte(payload), &interaction); err != nil { - slog.Error("failed to unmarshal interaction", "error", err) + slog.Error("failed to unmarshal interaction", + "error", err, + "payload_size", len(payload), + "remote_addr", r.RemoteAddr) writer.WriteHeader(http.StatusBadRequest) return } - // Verify the request signature. - if !c.verifyRequest(r) { - writer.WriteHeader(http.StatusUnauthorized) - return + // NOTE: Signature verification is handled by EventRouter before routing here. + // We don't verify again because FormValue() already consumed the body above. + + // Log the interaction for debugging + var actionIDs []string + if interaction.Type == slack.InteractionTypeBlockActions { + for _, action := range interaction.ActionCallback.BlockActions { + actionIDs = append(actionIDs, action.ActionID) + } } + slog.Info("processing interaction", + "type", interaction.Type, + "team_id", interaction.Team.ID, + "user_id", interaction.User.ID, + "user_name", interaction.User.Name, + "action_ids", actionIDs, + "remote_addr", r.RemoteAddr) + // Handle different interaction types. switch interaction.Type { case slack.InteractionTypeBlockActions: @@ -956,6 +1010,10 @@ func (c *Client) InteractionsHandler(writer http.ResponseWriter, r *http.Request slog.Debug("unhandled interaction type", "type", interaction.Type) } + slog.Debug("interaction handled successfully", + "type", interaction.Type, + "user_id", interaction.User.ID, + "response_status", http.StatusOK) writer.WriteHeader(http.StatusOK) } @@ -977,20 +1035,26 @@ func (c *Client) handleBlockAction(interaction *slack.InteractionCallback) { if handler != nil { // Refresh asynchronously to avoid blocking the response - //nolint:contextcheck // Use detached context for async button refresh - ensures operation completes even if parent context is cancelled go func(teamID, userID string) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() slog.Info("refreshing dashboard via button click", "team_id", teamID, - "user", userID) + "user_id", userID, + "trigger", "refresh_button") if err := handler(ctx, teamID, userID); err != nil { slog.Error("failed to refresh dashboard", "team_id", teamID, - "user", userID, + "user_id", userID, + "trigger", "refresh_button", "error", err) + } else { + slog.Info("successfully refreshed dashboard", + "team_id", teamID, + "user_id", userID, + "trigger", "refresh_button") } }(interaction.Team.ID, interaction.User.ID) } else { @@ -1129,7 +1193,7 @@ func isRateLimitError(err error) bool { } // PublishHomeView publishes a view to a user's app home with retry logic. -func (c *Client) PublishHomeView(userID string, blocks []slack.Block) error { +func (c *Client) PublishHomeView(ctx context.Context, userID string, blocks []slack.Block) error { view := slack.HomeTabViewRequest{ Type: "home", Blocks: slack.Blocks{BlockSet: blocks}, @@ -1137,12 +1201,21 @@ func (c *Client) PublishHomeView(userID string, blocks []slack.Block) error { err := retry.Do( func() error { - _, err := c.api.PublishView(userID, view, "") + // Properly omit hash field to avoid hash_conflict errors + _, err := c.api.PublishViewContext(ctx, slack.PublishViewContextRequest{ + UserID: userID, + View: view, + Hash: nil, // Properly omits hash field + }) if err != nil { if isRateLimitError(err) { slog.Warn("rate limited publishing home view, backing off", "user", userID) return err } + // invalid_auth should propagate up to home handler for proper retry with fresh client + if strings.Contains(err.Error(), "invalid_auth") { + return retry.Unrecoverable(err) + } // Don't retry on user_not_found if strings.Contains(err.Error(), "user_not_found") { return retry.Unrecoverable(err) @@ -1152,7 +1225,7 @@ func (c *Client) PublishHomeView(userID string, blocks []slack.Block) error { } return nil }, - retry.Attempts(5), + retry.Attempts(2), retry.Delay(time.Second), retry.MaxDelay(2*time.Minute), retry.DelayType(retry.BackOffDelay), diff --git a/pkg/home/ui.go b/pkg/home/ui.go index f003e67..e8d3dcc 100644 --- a/pkg/home/ui.go +++ b/pkg/home/ui.go @@ -2,6 +2,7 @@ package home import ( "fmt" + "net/url" "strings" "time" @@ -13,29 +14,58 @@ import ( func BuildBlocks(dashboard *Dashboard, primaryOrg string) []slack.Block { var blocks []slack.Block - // Header - matches dashboard's gradient header + // Header - gradient-inspired title blocks = append(blocks, slack.NewHeaderBlock( - slack.NewTextBlockObject("plain_text", "πŸš€ Ready to Review", false, false), + slack.NewTextBlockObject("plain_text", "πŸš€ Ready to Review", true, false), ), ) - // Organization monitoring section - show which orgs this workspace tracks - if len(dashboard.WorkspaceOrgs) > 0 { - var orgLinks []string - for _, org := range dashboard.WorkspaceOrgs { - configURL := fmt.Sprintf("https://github.com/%s/.codeGROOVE/blob/main/slack.yaml", org) - orgLinks = append(orgLinks, fmt.Sprintf("<%s|%s>", configURL, org)) - } - orgsText := fmt.Sprintf("*Monitoring:* %s", strings.Join(orgLinks, " β€’ ")) + counts := dashboard.Counts() - blocks = append(blocks, - slack.NewContextBlock( - "", - slack.NewTextBlockObject("mrkdwn", orgsText, false, false), + // Status overview - quick summary + statusEmoji := "✨" + statusText := "All clear" + if counts.IncomingBlocked > 0 || counts.OutgoingBlocked > 0 { + statusEmoji = "⚑" + statusText = "Action needed" + } + + blocks = append(blocks, + slack.NewSectionBlock( + slack.NewTextBlockObject("mrkdwn", + fmt.Sprintf("%s *%s* β€’ %d incoming β€’ %d outgoing", + statusEmoji, + statusText, + counts.IncomingTotal, + counts.OutgoingTotal), + false, + false, ), - ) + nil, + nil, + ), + ) + + // Organization monitoring + last updated + orgLinks := make([]string, 0, len(dashboard.WorkspaceOrgs)) + for _, org := range dashboard.WorkspaceOrgs { + // URL-escape org name to prevent injection + escaped := url.PathEscape(org) + orgLinks = append(orgLinks, fmt.Sprintf("<%s|%s>", + fmt.Sprintf("https://github.com/%s/.codeGROOVE/blob/main/slack.yaml", escaped), + org)) } + updated := time.Now().Format("Jan 2, 3:04pm MST") + ctx := fmt.Sprintf("Monitoring: %s β€’ Updated: %s", + strings.Join(orgLinks, ", "), + updated) + + blocks = append(blocks, + slack.NewContextBlock("", + slack.NewTextBlockObject("mrkdwn", ctx, false, false), + ), + ) // Refresh button blocks = append(blocks, @@ -44,24 +74,22 @@ func BuildBlocks(dashboard *Dashboard, primaryOrg string) []slack.Block { slack.NewButtonBlockElement( "refresh_dashboard", "refresh", - slack.NewTextBlockObject("plain_text", "πŸ”„ Refresh", false, false), - ), + slack.NewTextBlockObject("plain_text", "πŸ”„ Refresh Dashboard", true, false), + ).WithStyle("primary"), ), ) - counts := dashboard.Counts() + // Incoming PRs section + blocks = append(blocks, slack.NewDividerBlock()) + + incoming := fmt.Sprintf(":arrow_down: *Incoming PRs* (%d total)", counts.IncomingTotal) + if counts.IncomingBlocked > 0 { + incoming = fmt.Sprintf(":rotating_light: *Incoming PRs* β€’ *%d blocked on you* β€’ %d total", counts.IncomingBlocked, counts.IncomingTotal) + } - // Incoming section - matches dashboard's card-based sections blocks = append(blocks, - slack.NewDividerBlock(), slack.NewSectionBlock( - slack.NewTextBlockObject("mrkdwn", - fmt.Sprintf("*πŸͺΏ Incoming PRs* β€’ %d blocked β€’ %d total", - counts.IncomingBlocked, - counts.IncomingTotal), - false, - false, - ), + slack.NewTextBlockObject("mrkdwn", incoming, false, false), nil, nil, ), @@ -69,29 +97,27 @@ func BuildBlocks(dashboard *Dashboard, primaryOrg string) []slack.Block { if len(dashboard.IncomingPRs) == 0 { blocks = append(blocks, - slack.NewSectionBlock( - slack.NewTextBlockObject("mrkdwn", "_No incoming PRs_", false, false), - nil, - nil, + slack.NewContextBlock("", + slack.NewTextBlockObject("mrkdwn", "No incoming PRs β€’ You're all caught up!", false, false), ), ) } else { for i := range dashboard.IncomingPRs { - blocks = append(blocks, formatPRBlock(&dashboard.IncomingPRs[i])) + blocks = append(blocks, formatEnhancedPRBlock(&dashboard.IncomingPRs[i])) } } - // Outgoing section - matches dashboard's card-based sections + // Outgoing PRs section + blocks = append(blocks, slack.NewDividerBlock()) + + outgoing := fmt.Sprintf(":arrow_up: *Outgoing PRs* (%d total)", counts.OutgoingTotal) + if counts.OutgoingBlocked > 0 { + outgoing = fmt.Sprintf(":hourglass_flowing_sand: *Outgoing PRs* β€’ *%d waiting* β€’ %d total", counts.OutgoingBlocked, counts.OutgoingTotal) + } + blocks = append(blocks, - slack.NewDividerBlock(), slack.NewSectionBlock( - slack.NewTextBlockObject("mrkdwn", - fmt.Sprintf("*:popper: Outgoing PRs* β€’ %d blocked β€’ %d total", - counts.OutgoingBlocked, - counts.OutgoingTotal), - false, - false, - ), + slack.NewTextBlockObject("mrkdwn", outgoing, false, false), nil, nil, ), @@ -99,26 +125,25 @@ func BuildBlocks(dashboard *Dashboard, primaryOrg string) []slack.Block { if len(dashboard.OutgoingPRs) == 0 { blocks = append(blocks, - slack.NewSectionBlock( - slack.NewTextBlockObject("mrkdwn", "_No outgoing PRs_", false, false), - nil, - nil, + slack.NewContextBlock("", + slack.NewTextBlockObject("mrkdwn", "No outgoing PRs β€’ Time to ship something new!", false, false), ), ) } else { for i := range dashboard.OutgoingPRs { - blocks = append(blocks, formatPRBlock(&dashboard.OutgoingPRs[i])) + blocks = append(blocks, formatEnhancedPRBlock(&dashboard.OutgoingPRs[i])) } } - // Footer with link to comprehensive web dashboard - matches dashboard styling + // Footer - full dashboard link + // URL-escape org name to prevent injection + escapedOrg := url.PathEscape(primaryOrg) blocks = append(blocks, slack.NewDividerBlock(), - slack.NewContextBlock( - "", + slack.NewContextBlock("", slack.NewTextBlockObject("mrkdwn", - fmt.Sprintf("✨ Visit <%s|%s.ready-to-review.dev> for the full dashboard experience", - fmt.Sprintf("https://%s.ready-to-review.dev", primaryOrg), + fmt.Sprintf("πŸ“Š <%s|View full dashboard at %s.ready-to-review.dev>", + fmt.Sprintf("https://%s.ready-to-review.dev", escapedOrg), primaryOrg, ), false, @@ -130,41 +155,72 @@ func BuildBlocks(dashboard *Dashboard, primaryOrg string) []slack.Block { return blocks } -// formatPRBlock formats a single PR as a Slack block. -// Matches dashboard's card design with clean hierarchy. -func formatPRBlock(pr *PR) slack.Block { - // Status emoji - matches dashboard's visual indicators - // Using indigo/purple themed emojis to match dashboard color scheme - emoji := "πŸ”΅" // normal state (matches dashboard's indigo) - if pr.IsBlocked || pr.NeedsReview { - emoji = "🟣" // blocked/needs attention (matches dashboard's purple accent) +// formatEnhancedPRBlock formats a single PR with enhanced visual design. +// Inspired by dash.ready-to-review.dev with more informative, actionable display. +func formatEnhancedPRBlock(pr *PR) slack.Block { + // Status indicators - clear visual hierarchy + var emoji, status string + if pr.IsBlocked { + if pr.NeedsReview { + // Blocked on YOU - highest priority + emoji = "🚨" + status = "*BLOCKED ON YOU*" + } else { + // Blocked on author + emoji = "⏸️" + status = "Blocked on author" + } + } else if pr.NeedsReview { + // Ready for your review + emoji = "πŸ‘€" + status = "Ready for review" + } else { + // Waiting/in progress + emoji = "⏳" + status = "In progress" } - // Build PR line: 🟣 repo#number β€” action β€’ age - // Extract repo name from "owner/repo" - repoParts := strings.SplitN(pr.Repository, "/", 2) + // Extract repo name + parts := strings.SplitN(pr.Repository, "/", 2) repo := pr.Repository - if len(repoParts) == 2 { - repo = repoParts[1] + if len(parts) == 2 { + repo = parts[1] } - prRef := fmt.Sprintf("%s#%d", repo, pr.Number) + ref := fmt.Sprintf("%s#%d", repo, pr.Number) - line := fmt.Sprintf("%s <%s|*%s*>", emoji, pr.URL, prRef) + // Build main line with status + line := fmt.Sprintf("%s <%s|*%s*> β€’ %s", emoji, pr.URL, ref, status) - // Add action kind if present - matches dashboard's secondary text style + // Add action kind if present if pr.ActionKind != "" { - actionDisplay := strings.ReplaceAll(pr.ActionKind, "_", " ") - line = fmt.Sprintf("%s β†’ %s", line, actionDisplay) + action := strings.ReplaceAll(pr.ActionKind, "_", " ") + line = fmt.Sprintf("%s β€’ %s", line, action) } - // Add age - matches dashboard's tertiary text style - age := formatAge(pr.UpdatedAt) - line = fmt.Sprintf("%s β€’ `%s`", line, age) + // Add age indicator + // Inline formatAge since it's only called once (simplicity) + age := time.Since(pr.UpdatedAt) + var ageStr string + switch { + case age < time.Hour: + ageStr = fmt.Sprintf("%dm", int(age.Minutes())) + case age < 24*time.Hour: + ageStr = fmt.Sprintf("%dh", int(age.Hours())) + case age < 30*24*time.Hour: + ageStr = fmt.Sprintf("%dd", int(age.Hours()/24)) + case age < 365*24*time.Hour: + ageStr = fmt.Sprintf("%dmo", int(age.Hours()/(24*30))) + default: + ageStr = pr.UpdatedAt.Format("2006") + } + line = fmt.Sprintf("%s β€’ _updated %s ago_", line, ageStr) - // Title as secondary line (truncated if too long) - matches dashboard's card content + // Title on second line (truncated if needed) + // Use rune slicing to safely handle multi-byte UTF-8 characters title := pr.Title - if len(title) > 100 { - title = title[:97] + "..." + runes := []rune(title) + if len(runes) > 120 { + title = string(runes[:117]) + "..." } text := fmt.Sprintf("%s\n%s", line, title) @@ -175,27 +231,3 @@ func formatPRBlock(pr *PR) slack.Block { 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") -}