diff --git a/apps/backend/internal/github/client.go b/apps/backend/internal/github/client.go index 8beedecaef..e4f4173ccc 100644 --- a/apps/backend/internal/github/client.go +++ b/apps/backend/internal/github/client.go @@ -23,8 +23,11 @@ type Client interface { ListAuthoredPRs(ctx context.Context, owner, repo string) ([]*PR, error) // ListReviewRequestedPRs lists open PRs where the user's review is requested. - // filter is an optional GitHub search qualifier (e.g. "repo:owner/name" or "org:myorg"). - ListReviewRequestedPRs(ctx context.Context, filter string) ([]*PR, error) + // scope controls the search qualifier: ReviewScopeUser uses user-review-requested:@me, + // ReviewScopeUserAndTeams (or empty) uses review-requested:@me. + // filter is an optional additional search qualifier (e.g. "repo:owner/name" or "org:myorg"). + // customQuery, when non-empty, replaces the entire generated query. + ListReviewRequestedPRs(ctx context.Context, scope, filter, customQuery string) ([]*PR, error) // ListUserOrgs returns the GitHub organizations the authenticated user belongs to. ListUserOrgs(ctx context.Context) ([]GitHubOrg, error) diff --git a/apps/backend/internal/github/client_helpers.go b/apps/backend/internal/github/client_helpers.go index 7c20182508..8f894d8d7d 100644 --- a/apps/backend/internal/github/client_helpers.go +++ b/apps/backend/internal/github/client_helpers.go @@ -5,6 +5,25 @@ import ( "time" ) +// buildReviewSearchQuery assembles the full GitHub search query. +// When customQuery is non-empty, it is used verbatim as the entire query. +// Otherwise a query is built from scope + optional filter qualifier. +func buildReviewSearchQuery(scope, filter, customQuery string) string { + if customQuery != "" { + return customQuery + } + var base string + if scope == ReviewScopeUser { + base = "type:pr state:open user-review-requested:@me" + } else { + base = "type:pr state:open review-requested:@me" + } + if filter != "" { + base += " " + filter + } + return base +} + // getPRFeedback fetches aggregated feedback for a PR using any Client implementation. // This shared function eliminates duplication between GHClient and PATClient. func getPRFeedback(ctx context.Context, c Client, owner, repo string, number int) (*PRFeedback, error) { diff --git a/apps/backend/internal/github/client_helpers_test.go b/apps/backend/internal/github/client_helpers_test.go index e5c85fff6f..6c7f96c88a 100644 --- a/apps/backend/internal/github/client_helpers_test.go +++ b/apps/backend/internal/github/client_helpers_test.go @@ -5,6 +5,67 @@ import ( "time" ) +func TestBuildReviewSearchQuery(t *testing.T) { + tests := []struct { + name string + scope string + filter string + customQuery string + want string + }{ + { + name: "customQuery overrides everything", + scope: ReviewScopeUser, + filter: "repo:owner/name", + customQuery: "type:pr is:open assignee:@me", + want: "type:pr is:open assignee:@me", + }, + { + name: "user scope without filter", + scope: ReviewScopeUser, + want: "type:pr state:open user-review-requested:@me", + }, + { + name: "user scope with repo filter", + scope: ReviewScopeUser, + filter: "repo:owner/repo", + want: "type:pr state:open user-review-requested:@me repo:owner/repo", + }, + { + name: "user_and_teams scope without filter", + scope: ReviewScopeUserAndTeams, + want: "type:pr state:open review-requested:@me", + }, + { + name: "user_and_teams scope with org filter", + scope: ReviewScopeUserAndTeams, + filter: "org:myorg", + want: "type:pr state:open review-requested:@me org:myorg", + }, + { + name: "empty scope defaults to user_and_teams", + scope: "", + want: "type:pr state:open review-requested:@me", + }, + { + name: "empty customQuery falls through to scope logic", + scope: ReviewScopeUserAndTeams, + customQuery: "", + want: "type:pr state:open review-requested:@me", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildReviewSearchQuery(tt.scope, tt.filter, tt.customQuery) + if got != tt.want { + t.Errorf("buildReviewSearchQuery(%q, %q, %q) = %q, want %q", + tt.scope, tt.filter, tt.customQuery, got, tt.want) + } + }) + } +} + func TestConvertRawCheckRuns(t *testing.T) { conclusion := "success" summary := "All tests passed" diff --git a/apps/backend/internal/github/gh_client.go b/apps/backend/internal/github/gh_client.go index 408aecaf0d..79770f9842 100644 --- a/apps/backend/internal/github/gh_client.go +++ b/apps/backend/internal/github/gh_client.go @@ -114,11 +114,8 @@ func (c *GHClient) ListAuthoredPRs(ctx context.Context, owner, repo string) ([]* return c.parsePRList(out, owner, repo) } -func (c *GHClient) ListReviewRequestedPRs(ctx context.Context, filter string) ([]*PR, error) { - query := "type:pr state:open review-requested:@me" - if filter != "" { - query += " " + filter - } +func (c *GHClient) ListReviewRequestedPRs(ctx context.Context, scope, filter, customQuery string) ([]*PR, error) { + query := buildReviewSearchQuery(scope, filter, customQuery) out, err := c.run(ctx, "api", "search/issues", "-X", "GET", "-f", "q="+query, diff --git a/apps/backend/internal/github/models.go b/apps/backend/internal/github/models.go index 8be645d7c4..2eef0f8aae 100644 --- a/apps/backend/internal/github/models.go +++ b/apps/backend/internal/github/models.go @@ -119,6 +119,16 @@ type RepoFilter struct { Name string `json:"name"` } +// ReviewScope controls which GitHub search qualifier is used for review-requested PRs. +const ( + // ReviewScopeUser matches only PRs where the user is explicitly requested + // (user-review-requested:@me). + ReviewScopeUser = "user" + // ReviewScopeUserAndTeams matches PRs requested from the user or any of their teams + // (review-requested:@me). This is the default for backwards compatibility. + ReviewScopeUserAndTeams = "user_and_teams" +) + // ReviewWatch configures periodic polling for PRs needing the user's review. // Repos holds the list of repositories to monitor. An empty list means all repos. type ReviewWatch struct { @@ -131,6 +141,8 @@ type ReviewWatch struct { AgentProfileID string `json:"agent_profile_id" db:"agent_profile_id"` ExecutorProfileID string `json:"executor_profile_id" db:"executor_profile_id"` Prompt string `json:"prompt" db:"prompt"` + ReviewScope string `json:"review_scope" db:"review_scope"` + CustomQuery string `json:"custom_query" db:"custom_query"` Enabled bool `json:"enabled" db:"enabled"` PollIntervalSeconds int `json:"poll_interval_seconds" db:"poll_interval_seconds"` LastPolledAt *time.Time `json:"last_polled_at,omitempty" db:"last_polled_at"` @@ -180,6 +192,8 @@ type CreateReviewWatchRequest struct { AgentProfileID string `json:"agent_profile_id"` ExecutorProfileID string `json:"executor_profile_id"` Prompt string `json:"prompt"` + ReviewScope string `json:"review_scope"` + CustomQuery string `json:"custom_query"` PollIntervalSeconds int `json:"poll_interval_seconds"` } @@ -191,6 +205,8 @@ type UpdateReviewWatchRequest struct { AgentProfileID *string `json:"agent_profile_id,omitempty"` ExecutorProfileID *string `json:"executor_profile_id,omitempty"` Prompt *string `json:"prompt,omitempty"` + ReviewScope *string `json:"review_scope,omitempty"` + CustomQuery *string `json:"custom_query,omitempty"` Enabled *bool `json:"enabled,omitempty"` PollIntervalSeconds *int `json:"poll_interval_seconds,omitempty"` } diff --git a/apps/backend/internal/github/pat_client.go b/apps/backend/internal/github/pat_client.go index 7408cf1e7c..e9dee3bbcf 100644 --- a/apps/backend/internal/github/pat_client.go +++ b/apps/backend/internal/github/pat_client.go @@ -90,11 +90,8 @@ func (c *PATClient) ListAuthoredPRs(ctx context.Context, owner, repo string) ([] return result, nil } -func (c *PATClient) ListReviewRequestedPRs(ctx context.Context, filter string) ([]*PR, error) { - query := "type:pr state:open review-requested:@me" - if filter != "" { - query += " " + filter - } +func (c *PATClient) ListReviewRequestedPRs(ctx context.Context, scope, filter, customQuery string) ([]*PR, error) { + query := buildReviewSearchQuery(scope, filter, customQuery) var result struct { Items []patSearchItem `json:"items"` } diff --git a/apps/backend/internal/github/service.go b/apps/backend/internal/github/service.go index 85f5f9cf75..cbb4385de0 100644 --- a/apps/backend/internal/github/service.go +++ b/apps/backend/internal/github/service.go @@ -352,6 +352,10 @@ func (s *Service) CreateReviewWatch(ctx context.Context, req *CreateReviewWatchR if repos == nil { repos = []RepoFilter{} } + reviewScope := req.ReviewScope + if reviewScope == "" { + reviewScope = ReviewScopeUserAndTeams + } rw := &ReviewWatch{ WorkspaceID: req.WorkspaceID, WorkflowID: req.WorkflowID, @@ -360,6 +364,8 @@ func (s *Service) CreateReviewWatch(ctx context.Context, req *CreateReviewWatchR AgentProfileID: req.AgentProfileID, ExecutorProfileID: req.ExecutorProfileID, Prompt: req.Prompt, + ReviewScope: reviewScope, + CustomQuery: req.CustomQuery, Enabled: true, PollIntervalSeconds: req.PollIntervalSeconds, } @@ -428,6 +434,12 @@ func (s *Service) UpdateReviewWatch(ctx context.Context, id string, req *UpdateR if req.Prompt != nil { rw.Prompt = *req.Prompt } + if req.ReviewScope != nil { + rw.ReviewScope = *req.ReviewScope + } + if req.CustomQuery != nil { + rw.CustomQuery = *req.CustomQuery + } if req.Enabled != nil { rw.Enabled = *req.Enabled } @@ -453,7 +465,7 @@ func (s *Service) CheckReviewWatch(ctx context.Context, watch *ReviewWatch) ([]* zap.String("watch_id", watch.ID), zap.Int("repo_filters", len(watch.Repos))) - prs, err := s.fetchReviewPRs(ctx, watch.Repos) + prs, err := s.fetchReviewPRs(ctx, watch) if err != nil { return nil, err } @@ -483,23 +495,31 @@ func (s *Service) CheckReviewWatch(ctx context.Context, watch *ReviewWatch) ([]* return newPRs, nil } -// fetchReviewPRs fetches PRs needing review for the given repo filters. -// An empty repos slice means all repos. Entries with empty Name are org-level filters. -func (s *Service) fetchReviewPRs(ctx context.Context, repos []RepoFilter) ([]*PR, error) { - if len(repos) == 0 { - return s.client.ListReviewRequestedPRs(ctx, "") +// fetchReviewPRs fetches PRs needing review based on the watch configuration. +// If CustomQuery is set, it is used as the full search query (bypassing scope and repo filters). +// Otherwise, scope + repo filters determine the query. +func (s *Service) fetchReviewPRs(ctx context.Context, watch *ReviewWatch) ([]*PR, error) { + customQuery := watch.CustomQuery + scope := watch.ReviewScope + + if customQuery != "" { + return s.client.ListReviewRequestedPRs(ctx, "", "", customQuery) + } + + if len(watch.Repos) == 0 { + return s.client.ListReviewRequestedPRs(ctx, scope, "", "") } var allPRs []*PR seen := make(map[string]bool) - for _, repo := range repos { + for _, repo := range watch.Repos { var filter string if repo.Name == "" { filter = "org:" + repo.Owner } else { filter = fmt.Sprintf("repo:%s/%s", repo.Owner, repo.Name) } - prs, err := s.client.ListReviewRequestedPRs(ctx, filter) + prs, err := s.client.ListReviewRequestedPRs(ctx, scope, filter, "") if err != nil { s.logger.Error("failed to list review PRs", zap.String("filter", filter), zap.Error(err)) diff --git a/apps/backend/internal/github/store.go b/apps/backend/internal/github/store.go index bb0833f622..5d5a0c070e 100644 --- a/apps/backend/internal/github/store.go +++ b/apps/backend/internal/github/store.go @@ -80,6 +80,8 @@ const createTablesSQL = ` agent_profile_id TEXT NOT NULL, executor_profile_id TEXT NOT NULL, prompt TEXT DEFAULT '', + review_scope TEXT NOT NULL DEFAULT 'user_and_teams', + custom_query TEXT NOT NULL DEFAULT '', enabled BOOLEAN DEFAULT 1, poll_interval_seconds INTEGER DEFAULT 300, last_polled_at DATETIME, @@ -246,11 +248,12 @@ func (s *Store) CreateReviewWatch(ctx context.Context, rw *ReviewWatch) error { rw.ReposJSON = string(reposJSON) _, err = s.db.ExecContext(ctx, ` INSERT INTO github_review_watches (id, workspace_id, workflow_id, workflow_step_id, repos, - agent_profile_id, executor_profile_id, prompt, enabled, poll_interval_seconds, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + agent_profile_id, executor_profile_id, prompt, review_scope, custom_query, + enabled, poll_interval_seconds, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, rw.ID, rw.WorkspaceID, rw.WorkflowID, rw.WorkflowStepID, rw.ReposJSON, - rw.AgentProfileID, rw.ExecutorProfileID, rw.Prompt, rw.Enabled, rw.PollIntervalSeconds, - rw.CreatedAt, rw.UpdatedAt) + rw.AgentProfileID, rw.ExecutorProfileID, rw.Prompt, rw.ReviewScope, rw.CustomQuery, + rw.Enabled, rw.PollIntervalSeconds, rw.CreatedAt, rw.UpdatedAt) return err } @@ -320,11 +323,13 @@ func (s *Store) UpdateReviewWatch(ctx context.Context, rw *ReviewWatch) error { _, err = s.db.ExecContext(ctx, ` UPDATE github_review_watches SET workflow_id = ?, workflow_step_id = ?, repos = ?, agent_profile_id = ?, executor_profile_id = ?, - prompt = ?, enabled = ?, poll_interval_seconds = ?, last_polled_at = ?, updated_at = ? + prompt = ?, review_scope = ?, custom_query = ?, + enabled = ?, poll_interval_seconds = ?, last_polled_at = ?, updated_at = ? WHERE id = ?`, rw.WorkflowID, rw.WorkflowStepID, rw.ReposJSON, rw.AgentProfileID, rw.ExecutorProfileID, - rw.Prompt, rw.Enabled, rw.PollIntervalSeconds, rw.LastPolledAt, rw.UpdatedAt, rw.ID) + rw.Prompt, rw.ReviewScope, rw.CustomQuery, + rw.Enabled, rw.PollIntervalSeconds, rw.LastPolledAt, rw.UpdatedAt, rw.ID) return err } diff --git a/apps/web/components/github/review-watch-dialog.tsx b/apps/web/components/github/review-watch-dialog.tsx index 4f02cb7ece..b7764f7aa2 100644 --- a/apps/web/components/github/review-watch-dialog.tsx +++ b/apps/web/components/github/review-watch-dialog.tsx @@ -14,6 +14,7 @@ import { DialogFooter, } from "@kandev/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { Textarea } from "@kandev/ui/textarea"; import { IconInfoCircle } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; import { useAppStore } from "@/components/state-provider"; @@ -42,6 +43,11 @@ type ReviewWatchDialogProps = { onUpdate: (id: string, req: UpdateReviewWatchRequest) => Promise; }; +const QUERY_TEMPLATES = { + meAndTeams: "type:pr state:open review-requested:@me", + me: "type:pr state:open user-review-requested:@me", +} as const; + type FormState = { selectedRepos: RepoFilter[]; allRepos: boolean; @@ -50,6 +56,7 @@ type FormState = { agentProfileId: string; executorProfileId: string; prompt: string; + customQuery: string; enabled: boolean; pollInterval: number; }; @@ -64,6 +71,7 @@ const defaultFormState: FormState = { agentProfileId: "", executorProfileId: "", prompt: DEFAULT_REVIEW_WATCH_PROMPT, + customQuery: QUERY_TEMPLATES.meAndTeams, enabled: true, pollInterval: 300, }; @@ -78,6 +86,7 @@ function formStateFromWatch(watch: ReviewWatch): FormState { agentProfileId: watch.agent_profile_id, executorProfileId: watch.executor_profile_id, prompt: watch.prompt || DEFAULT_REVIEW_WATCH_PROMPT, + customQuery: watch.custom_query || QUERY_TEMPLATES.meAndTeams, enabled: watch.enabled, pollInterval: watch.poll_interval_seconds, }; @@ -165,8 +174,91 @@ function useWatchFormData(workspaceId: string) { return { workflows, agentProfiles, allExecutorProfiles }; } +// --- Section header --- + +function SectionHeader({ children }: { children: React.ReactNode }) { + return ( +
+ + {children} + +
+
+ ); +} + // --- Form field groups --- +function QueryField({ + form, + setForm, +}: { + form: FormState; + setForm: React.Dispatch>; +}) { + const current = form.customQuery.trim(); + const isMeAndTeams = current === QUERY_TEMPLATES.meAndTeams; + const isMe = current === QUERY_TEMPLATES.me; + + return ( +
+
+ +
+ + + + + + + PRs where you or any of your teams are requested as reviewers + + + + + + + + + + Only PRs where you are explicitly requested as a reviewer (not via team membership) + + + +
+
+