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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ func run(ctx context.Context, cancel context.CancelFunc, cfg *config.ServerConfi
configManager := config.New(ctx)

// Initialize GitHub installation manager.
githubManager, err := github.NewManager(ctx, cfg.GitHubAppID, cfg.GitHubPrivateKey)
githubManager, err := github.NewManager(ctx, cfg.GitHubAppID, cfg.GitHubPrivateKey, cfg.AllowPersonalAccounts)
if err != nil {
slog.Error("failed to initialize GitHub installation manager", "error", err)
cancel() // Ensure cleanup happens before exit
Expand Down Expand Up @@ -856,18 +856,23 @@ func loadConfig() (*config.ServerConfig, error) {

slog.Info("loading configuration values")

// Parse personal accounts flag (default: false for DoS protection)
allowPersonalAccounts := os.Getenv("ALLOW_PERSONAL_ACCOUNTS") == "true"

cfg := &config.ServerConfig{
DataDir: dataDir,
SlackSigningSecret: getSecretValue("SLACK_SIGNING_SECRET"),
GitHubAppID: os.Getenv("GITHUB_APP_ID"), // Not a secret, just config
GitHubPrivateKey: githubPrivateKey,
SprinklerURL: sprinklerURL,
DataDir: dataDir,
SlackSigningSecret: getSecretValue("SLACK_SIGNING_SECRET"),
GitHubAppID: os.Getenv("GITHUB_APP_ID"), // Not a secret, just config
GitHubPrivateKey: githubPrivateKey,
SprinklerURL: sprinklerURL,
AllowPersonalAccounts: allowPersonalAccounts,
}

slog.Info("configuration loaded",
"has_slack_signing_secret", cfg.SlackSigningSecret != "",
"has_github_app_id", cfg.GitHubAppID != "",
"has_github_private_key", cfg.GitHubPrivateKey != "")
"has_github_private_key", cfg.GitHubPrivateKey != "",
"allow_personal_accounts", cfg.AllowPersonalAccounts)

// Validate required fields
if cfg.SlackSigningSecret == "" {
Expand Down
11 changes: 6 additions & 5 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,12 @@ const (

// ServerConfig holds the server configuration from environment variables.
type ServerConfig struct {
DataDir string
SlackSigningSecret string
GitHubAppID string
GitHubPrivateKey string
SprinklerURL string
DataDir string
SlackSigningSecret string
GitHubAppID string
GitHubPrivateKey string
SprinklerURL string
AllowPersonalAccounts bool // Allow processing GitHub personal accounts (default: false for DoS protection)
}

// RepoConfig represents the slack.yaml configuration for a GitHub org.
Expand Down
26 changes: 18 additions & 8 deletions internal/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -635,14 +635,15 @@ func (c *Client) InstallationToken(ctx context.Context) string {

// Manager manages multiple GitHub App installations.
type Manager struct {
privateKey *rsa.PrivateKey
clients map[string]*Client // org -> client
appID string
mu sync.RWMutex
privateKey *rsa.PrivateKey
clients map[string]*Client // org -> client
appID string
allowPersonalAccounts bool // Allow processing personal accounts (default: false for DoS protection)
mu sync.RWMutex
}

// NewManager creates a new installation manager.
func NewManager(ctx context.Context, appID, privateKeyPEM string) (*Manager, error) {
func NewManager(ctx context.Context, appID, privateKeyPEM string, allowPersonalAccounts bool) (*Manager, error) {
// Parse the private key.
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
Expand All @@ -669,9 +670,10 @@ func NewManager(ctx context.Context, appID, privateKeyPEM string) (*Manager, err
}

m := &Manager{
clients: make(map[string]*Client),
appID: appID,
privateKey: key,
clients: make(map[string]*Client),
appID: appID,
privateKey: key,
allowPersonalAccounts: allowPersonalAccounts,
}

// Discover installations at startup.
Expand Down Expand Up @@ -754,6 +756,14 @@ func (m *Manager) RefreshInstallations(ctx context.Context) error {
continue
}

// Skip personal accounts if not explicitly allowed (DoS protection)
if !m.allowPersonalAccounts && inst.Account.GetType() == "User" {
slog.Debug("skipping personal account",
"account", inst.Account.GetLogin(),
"type", "User")
continue
}

org := inst.Account.GetLogin()

// Create client for this installation.
Expand Down
3 changes: 3 additions & 0 deletions internal/slack/home_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ func (h *HomeHandler) HandleAppHomeOpened(ctx context.Context, teamID, slackUser
return h.publishPlaceholderHome(slackClient, slackUserID)
}

// Add workspace orgs to dashboard for UI display
dashboard.WorkspaceOrgs = workspaceOrgs

// Build Block Kit UI - use first org as primary
blocks := home.BuildBlocks(dashboard, workspaceOrgs[0])

Expand Down
49 changes: 48 additions & 1 deletion internal/slack/slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,7 @@ func (c *Client) EventsHandler(writer http.ResponseWriter, r *http.Request) {
c.homeViewHandlerMu.RUnlock()

if handler != nil {
//nolint:contextcheck // Use detached context for async event processing - prevents webhook events from being lost during shutdown
go func(teamID, userID string) {
homeCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
Expand Down Expand Up @@ -858,7 +859,8 @@ func (c *Client) InteractionsHandler(writer http.ResponseWriter, r *http.Request
switch interaction.Type {
case slack.InteractionTypeBlockActions:
// Handle block actions (buttons, selects, etc.).
slog.Debug("received block action", "interaction", interaction)
//nolint:contextcheck // handleBlockAction spawns async goroutines with detached contexts - this is intentional
c.handleBlockAction(&interaction)
case slack.InteractionTypeViewSubmission:
// Handle modal submissions.
slog.Debug("received view submission", "interaction", interaction)
Expand All @@ -870,6 +872,51 @@ func (c *Client) InteractionsHandler(writer http.ResponseWriter, r *http.Request
writer.WriteHeader(http.StatusOK)
}

// handleBlockAction handles block action interactions (button clicks, etc.).
func (c *Client) handleBlockAction(interaction *slack.InteractionCallback) {
// Process each action in the callback
for _, action := range interaction.ActionCallback.BlockActions {
slog.Debug("processing block action",
"action_id", action.ActionID,
"user", interaction.User.ID,
"team", interaction.Team.ID)

switch action.ActionID {
case "refresh_dashboard":
// Trigger home view refresh
c.homeViewHandlerMu.RLock()
handler := c.homeViewHandler
c.homeViewHandlerMu.RUnlock()

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)

if err := handler(ctx, teamID, userID); err != nil {
slog.Error("failed to refresh dashboard",
"team_id", teamID,
"user", userID,
"error", err)
}
}(interaction.Team.ID, interaction.User.ID)
} else {
slog.Warn("refresh requested but no home view handler registered",
"user", interaction.User.ID)
}

default:
slog.Debug("unhandled action_id", "action_id", action.ActionID)
}
}
}

// SlashCommandHandler handles Slack slash commands.
func (c *Client) SlashCommandHandler(writer http.ResponseWriter, r *http.Request) {
// Verify the request signature.
Expand Down
29 changes: 29 additions & 0 deletions pkg/home/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,35 @@ func BuildBlocks(dashboard *Dashboard, primaryOrg string) []slack.Block {
),
)

// 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, " • "))

blocks = append(blocks,
slack.NewContextBlock(
"",
slack.NewTextBlockObject("mrkdwn", orgsText, false, false),
),
)
}

// Refresh button
blocks = append(blocks,
slack.NewActionBlock(
"refresh_actions",
slack.NewButtonBlockElement(
"refresh_dashboard",
"refresh",
slack.NewTextBlockObject("plain_text", "🔄 Refresh", false, false),
),
),
)

counts := dashboard.Counts()

// Incoming section - matches dashboard's card-based sections
Expand Down