From 85aa3ac0aad80c9e83426e31bf39c83a6a9ecf9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Fri, 24 Apr 2026 10:38:49 +0100 Subject: [PATCH] feat(github): add /github page for PRs and issues (#687) * feat(github): add /github page for PRs and issues Dedicated page under /github lists pull requests and issues across the authenticated user's repos with preset filters, custom GitHub search queries, per-repo narrowing, saved queries, and one-click task launching for any PR or issue. Performance: - Cache user PR/issue search responses for 30s with singleflight to coalesce concurrent misses, so preset switching and pagination avoid upstream round trips. - Replace the per-row PR status fetch (N+1) with a batch endpoint POST /api/v1/github/prs/statuses that fans out concurrently on the backend with a semaphore and caches per-PR. Co-Authored-By: Claude Sonnet 4.6 * style: apply Prettier formatting Co-Authored-By: Claude Sonnet 4.6 * fix: address review feedback on PR cache + status batching - Harden searchCacheKey against separator collisions from user-controllable customQuery (length-prefix encoding). - Drop repositories.loaded hydration when inner workspace fetch fails so the client refetches instead of trusting a partial snapshot. - Guard useGitHubSearch with a monotonic request counter to drop stale responses when filter+page changes overlap. - Fix usePRStatuses teardown: depend only on composed key so a new array identity per render doesn't cancel the in-flight batch. - Correct godocs (duplicate, outdated state:open claim). Co-Authored-By: Claude Sonnet 4.6 * fix: address additional review feedback - Restore state:open for issue watcher: buildIssueSearchQuery no longer injects it as a default, so existing watches were starting to surface closed issues. Inject state:open in buildIssueFilter so only the watcher path is affected; the /github page presets already set their own state qualifiers. - countCheckResults now excludes in-progress checks from the denominator so a `1/3` badge reflects pass rate on completed checks, not a mix of running and failed. The pending state is still carried via ChecksState. - usePRStatuses retries after a transient fetch error (only mark the key as completed once the fetch resolves). This also fixes missing badges under React Strict Mode's intentional double-mount, since the first effect's cleanup fires before the response lands and the guard stays unset for the second mount to retry. Co-Authored-By: Claude Sonnet 4.6 * test(github): add tests for my-github hooks Covers use-committed-query, buildParams from use-github-search, use-saved-presets readStorage validation, use-known-repos recordForKey accumulator semantics, and use-pr-statuses batch fetch lifecycle. Co-Authored-By: Claude Sonnet 4.6 * fix: address review feedback on search cache and repo accumulator - Clamp perPage before composing the search cache key so identical pages (e.g. perPage=150 and =100) share a cache entry instead of creating duplicates keyed on the raw value. - Add resetKnownReposStore and call it on /github unmount so the module-level repo accumulator doesn't carry across navigations. - Rename use-pr-statuses test to match its assertion (it verifies stale statuses are cleared when the key changes and fetch fails). Co-Authored-By: Claude Sonnet 4.6 * style: apply Prettier formatting Co-Authored-By: Claude Sonnet 4.6 * fix(web): distinguish blocked from dirty in PR mergeable badge GitHub's blocked state (required reviews/checks/branch protection) is not a merge conflict, so don't label it "Conflicts". Surface it as "Blocked" with the pending tone; keep "Conflicts" for dirty. Co-Authored-By: Claude Opus 4.7 * fix: address remaining /github code review comments - Route PR status + batch through handleSearchError so ErrNoClient surfaces as 503 instead of 500 (matches the search endpoints). - Sort workflow steps by position in the quick-task-launcher default pick, matching the convention in issue-watch-dialog. - Make PresetItem keyboard-accessible: role=button, tabIndex, and Enter/Space handling on the wrapping div (it contains a nested delete button, so it can't itself be a button). - Extract useRepoOptions out of useGitHubPageState to bring the hook back under the 100-line complexity limit. - Honor ctx.Done() when acquiring the PR-status batch semaphore so a cancelled HTTP request releases queued goroutines instead of running each upstream fetch to completion. - Clarify parsePaginationQuery doc: the upper bound (100) is enforced by clampSearchPage in the client layer, not here. Co-Authored-By: Claude Opus 4.7 * fix(web): address /github review follow-ups - Merge duplicate @/lib/utils imports in pr-list. - Use rel="noopener noreferrer" on external PR/issue links (codebase convention) and add cursor-pointer per the UI guidelines. - Rewrite onOpenSaveDialog as an explicit if-block so the callback returns void rather than boolean | void. - Use PaginationEllipsis in its own PaginationItem so the ellipsis and the adjacent page link are separate list items instead of visually fused into a single cell. Co-Authored-By: Claude Opus 4.7 * refactor(web): derive QuickTaskLauncher dialog state from props Replaces the useEffect+setState anti-pattern with useMemo-derived state driven directly by the payload prop. Drops the eslint suppression that was needed for react-hooks/set-state-in-effect. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Kandev Agent Co-authored-by: Claude Sonnet 4.6 --- apps/backend/internal/github/client.go | 12 + .../backend/internal/github/client_helpers.go | 81 +++- .../internal/github/client_helpers_test.go | 49 +++ apps/backend/internal/github/controller.go | 129 +++++++ .../internal/github/controller_test.go | 9 + apps/backend/internal/github/gh_client.go | 60 ++- apps/backend/internal/github/mock_client.go | 22 ++ apps/backend/internal/github/models.go | 19 + apps/backend/internal/github/noop_client.go | 12 + apps/backend/internal/github/pat_client.go | 49 ++- apps/backend/internal/github/service.go | 172 ++++++++- .../internal/github/service_issue_test.go | 32 ++ .../internal/github/service_pr_status_test.go | 122 ++++++ .../github/service_search_cache_test.go | 91 +++++ apps/backend/internal/github/ttl_cache.go | 129 +++++++ .../backend/internal/github/ttl_cache_test.go | 161 ++++++++ apps/web/app/github/github-page-client.tsx | 352 ++++++++++++++++++ apps/web/app/github/page.tsx | 91 +++++ .../github/my-github/issue-list.tsx | 160 ++++++++ .../github/my-github/list-toolbar.tsx | 108 ++++++ .../components/github/my-github/pr-list.tsx | 156 ++++++++ .../github/my-github/pr-status-badges.tsx | 107 ++++++ .../github/my-github/presets-sidebar.tsx | 238 ++++++++++++ .../github/my-github/quick-task-launcher.tsx | 202 ++++++++++ .../github/my-github/results-pagination.tsx | 99 +++++ .../github/my-github/save-preset-dialog.tsx | 134 +++++++ .../github/my-github/search-bar.tsx | 89 +++++ .../my-github/use-committed-query.test.ts | 40 ++ .../github/my-github/use-committed-query.ts | 21 ++ .../my-github/use-github-search.test.ts | 49 +++ .../github/my-github/use-github-search.ts | 112 ++++++ .../github/my-github/use-known-repos.test.ts | 44 +++ .../github/my-github/use-known-repos.ts | 72 ++++ .../github/my-github/use-pr-statuses.test.ts | 130 +++++++ .../github/my-github/use-pr-statuses.ts | 61 +++ .../my-github/use-saved-presets.test.ts | 71 ++++ .../github/my-github/use-saved-presets.ts | 90 +++++ apps/web/components/kanban/kanban-header.tsx | 21 ++ .../components/task-create-dialog-state.ts | 9 +- .../components/task-create-dialog-types.ts | 3 + apps/web/lib/api/domains/github-api.ts | 64 ++++ apps/web/lib/types/github.ts | 43 +++ 42 files changed, 3688 insertions(+), 27 deletions(-) create mode 100644 apps/backend/internal/github/service_pr_status_test.go create mode 100644 apps/backend/internal/github/service_search_cache_test.go create mode 100644 apps/backend/internal/github/ttl_cache.go create mode 100644 apps/backend/internal/github/ttl_cache_test.go create mode 100644 apps/web/app/github/github-page-client.tsx create mode 100644 apps/web/app/github/page.tsx create mode 100644 apps/web/components/github/my-github/issue-list.tsx create mode 100644 apps/web/components/github/my-github/list-toolbar.tsx create mode 100644 apps/web/components/github/my-github/pr-list.tsx create mode 100644 apps/web/components/github/my-github/pr-status-badges.tsx create mode 100644 apps/web/components/github/my-github/presets-sidebar.tsx create mode 100644 apps/web/components/github/my-github/quick-task-launcher.tsx create mode 100644 apps/web/components/github/my-github/results-pagination.tsx create mode 100644 apps/web/components/github/my-github/save-preset-dialog.tsx create mode 100644 apps/web/components/github/my-github/search-bar.tsx create mode 100644 apps/web/components/github/my-github/use-committed-query.test.ts create mode 100644 apps/web/components/github/my-github/use-committed-query.ts create mode 100644 apps/web/components/github/my-github/use-github-search.test.ts create mode 100644 apps/web/components/github/my-github/use-github-search.ts create mode 100644 apps/web/components/github/my-github/use-known-repos.test.ts create mode 100644 apps/web/components/github/my-github/use-known-repos.ts create mode 100644 apps/web/components/github/my-github/use-pr-statuses.test.ts create mode 100644 apps/web/components/github/my-github/use-pr-statuses.ts create mode 100644 apps/web/components/github/my-github/use-saved-presets.test.ts create mode 100644 apps/web/components/github/my-github/use-saved-presets.ts diff --git a/apps/backend/internal/github/client.go b/apps/backend/internal/github/client.go index 7140695544..b78c662827 100644 --- a/apps/backend/internal/github/client.go +++ b/apps/backend/internal/github/client.go @@ -70,6 +70,18 @@ type Client interface { // customQuery, when non-empty, replaces the entire generated query. ListIssues(ctx context.Context, filter, customQuery string) ([]*Issue, error) + // SearchPRs searches for PRs matching the given query. + // filter is an optional additional search qualifier (e.g. "author:@me" or "repo:owner/name"). + // customQuery, when non-empty, replaces the entire generated query. + SearchPRs(ctx context.Context, filter, customQuery string) ([]*PR, error) + + // SearchPRsPaged is the paginated variant of SearchPRs. page is 1-indexed; + // perPage is clamped to GitHub's 1..100 range by the implementation. + SearchPRsPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*PRSearchPage, error) + + // ListIssuesPaged is the paginated variant of ListIssues. + ListIssuesPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*IssueSearchPage, error) + // GetIssueState returns the state of a single issue ("open" or "closed"). GetIssueState(ctx context.Context, owner, repo string, number int) (string, error) } diff --git a/apps/backend/internal/github/client_helpers.go b/apps/backend/internal/github/client_helpers.go index 6ebfc81ee1..ff16c2292e 100644 --- a/apps/backend/internal/github/client_helpers.go +++ b/apps/backend/internal/github/client_helpers.go @@ -89,6 +89,7 @@ func getPRStatus(ctx context.Context, c Client, owner, repo string, number int) checks = []CheckRun{} } reviewState, pendingReviewCount := deriveReviewSyncState(pr, reviews) + total, passing := countCheckResults(checks) return &PRStatus{ PR: pr, ReviewState: reviewState, @@ -96,9 +97,36 @@ func getPRStatus(ctx context.Context, c Client, owner, repo string, number int) MergeableState: pr.MergeableState, ReviewCount: len(reviews), PendingReviewCount: pendingReviewCount, + ChecksTotal: total, + ChecksPassing: passing, }, nil } +// countCheckResults returns (total, passing) counts using the same logic as +// computeOverallCheckStatus: skipped/neutral and still-running checks are +// excluded from the total so the X/Y badge communicates pass rate rather +// than conflating in-flight checks with failures. The pending state is +// carried separately via ChecksState. +func countCheckResults(checks []CheckRun) (int, int) { + total, passing := 0, 0 + for _, c := range checks { + if c.Status != checkStatusCompleted { + continue + } + switch c.Conclusion { + case checkConclusionSkipped, checkConclusionNeutral: + continue + case checkConclusionFail, checkConclusionTimedOut, + checkConclusionCancelled, checkConclusionActionRequired: + total++ + default: + total++ + passing++ + } + } + return total, passing +} + // convertRawCheckRuns converts raw ghCheckRun structs into the domain CheckRun type. func convertRawCheckRuns(raw []ghCheckRun) []CheckRun { checks := make([]CheckRun, len(raw)) @@ -304,18 +332,59 @@ func convertSearchItemToPR( } } +// clampSearchPage normalizes page and perPage to GitHub's Search API limits: +// page >= 1, 1 <= perPage <= 100. Defaults: page=1, perPage=50. +func clampSearchPage(page, perPage int) (int, int) { + if page < 1 { + page = 1 + } + if perPage <= 0 { + perPage = 50 + } + if perPage > 100 { + perPage = 100 + } + return page, perPage +} + +// hasTypeQualifier reports whether the query already pins the result type via +// `type:pr`, `type:issue`, `is:pr`, or `is:issue` (case-insensitive). +func hasTypeQualifier(query string) bool { + lower := strings.ToLower(query) + for _, tok := range strings.Fields(lower) { + switch tok { + case "type:pr", "type:issue", "is:pr", "is:issue": + return true + } + } + return false +} + // buildIssueSearchQuery assembles a GitHub search query for issues. -// When customQuery is non-empty, it is used verbatim as the entire query. -// Otherwise a query is built from optional filter qualifiers. +// The `type:issue` qualifier is always injected (unless the caller already +// pinned it) because `/search/issues` returns both PRs and issues otherwise. func buildIssueSearchQuery(filter, customQuery string) string { + return buildSearchQuery("type:issue", filter, customQuery) +} + +// buildPRSearchQuery assembles a GitHub search query for pull requests. +// The `type:pr` qualifier is always injected (unless the caller already pinned +// it) because `/search/issues` returns both PRs and issues otherwise. +func buildPRSearchQuery(filter, customQuery string) string { + return buildSearchQuery("type:pr", filter, customQuery) +} + +func buildSearchQuery(typeQualifier, filter, customQuery string) string { if customQuery != "" { - return customQuery + if hasTypeQualifier(customQuery) { + return customQuery + } + return typeQualifier + " " + customQuery } - base := "type:issue state:open" if filter != "" { - base += " " + filter + return typeQualifier + " " + filter } - return base + return typeQualifier } // issueSearchItem is an issue item from the GitHub search API. diff --git a/apps/backend/internal/github/client_helpers_test.go b/apps/backend/internal/github/client_helpers_test.go index 0578f68796..6500215993 100644 --- a/apps/backend/internal/github/client_helpers_test.go +++ b/apps/backend/internal/github/client_helpers_test.go @@ -399,6 +399,55 @@ func TestHasFailingChecks(t *testing.T) { } } +func TestCountCheckResults(t *testing.T) { + tests := []struct { + name string + checks []CheckRun + wantTotal, wantPass int + }{ + { + name: "empty", + wantTotal: 0, wantPass: 0, + }, + { + name: "in-progress checks excluded from total", + checks: []CheckRun{ + {Status: checkStatusCompleted, Conclusion: checkConclusionSuccess}, + {Status: "in_progress", Conclusion: ""}, + {Status: "queued", Conclusion: ""}, + }, + wantTotal: 1, wantPass: 1, + }, + { + name: "skipped and neutral are excluded", + checks: []CheckRun{ + {Status: checkStatusCompleted, Conclusion: checkConclusionSuccess}, + {Status: checkStatusCompleted, Conclusion: checkConclusionSkipped}, + {Status: checkStatusCompleted, Conclusion: checkConclusionNeutral}, + }, + wantTotal: 1, wantPass: 1, + }, + { + name: "failures count toward total but not passing", + checks: []CheckRun{ + {Status: checkStatusCompleted, Conclusion: checkConclusionSuccess}, + {Status: checkStatusCompleted, Conclusion: checkConclusionFail}, + {Status: checkStatusCompleted, Conclusion: checkConclusionTimedOut}, + }, + wantTotal: 3, wantPass: 1, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + total, passing := countCheckResults(tt.checks) + if total != tt.wantTotal || passing != tt.wantPass { + t.Errorf("countCheckResults = (%d, %d), want (%d, %d)", + total, passing, tt.wantTotal, tt.wantPass) + } + }) + } +} + func TestHasChangesRequested(t *testing.T) { tests := []struct { name string diff --git a/apps/backend/internal/github/controller.go b/apps/backend/internal/github/controller.go index f8080c95bd..48137235b9 100644 --- a/apps/backend/internal/github/controller.go +++ b/apps/backend/internal/github/controller.go @@ -34,6 +34,8 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { api.GET("/prs/:owner/:repo/:number", c.httpGetPRFeedback) api.GET("/prs/:owner/:repo/:number/info", c.httpGetPRInfo) + api.GET("/prs/:owner/:repo/:number/status", c.httpGetPRStatus) + api.POST("/prs/statuses", c.httpGetPRStatusesBatch) api.POST("/prs/:owner/:repo/:number/reviews", c.httpSubmitReview) api.GET("/watches/pr", c.httpListPRWatches) @@ -57,6 +59,9 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { api.GET("/repos/search", c.httpSearchRepos) api.GET("/repos/:owner/:repo/branches", c.httpListRepoBranches) + api.GET("/user/prs", c.httpSearchUserPRs) + api.GET("/user/issues", c.httpSearchUserIssues) + api.GET("/stats", c.httpGetStats) } @@ -128,6 +133,52 @@ func (c *Controller) httpGetPRFeedback(ctx *gin.Context) { ctx.JSON(http.StatusOK, feedback) } +func (c *Controller) httpGetPRStatus(ctx *gin.Context) { + owner := ctx.Param("owner") + repo := ctx.Param("repo") + numberStr := ctx.Param("number") + number, err := strconv.Atoi(numberStr) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid PR number"}) + return + } + status, err := c.service.GetPRStatus(ctx.Request.Context(), owner, repo, number) + if err != nil { + c.handleSearchError(ctx, err) + return + } + ctx.JSON(http.StatusOK, status) +} + +// prStatusesBatchMaxRefs caps how many PRs a single batch request can ask for. +// The list page defaults to 25 per page and can go up to 100; double that +// gives slack without letting a rogue client ask for thousands. +const prStatusesBatchMaxRefs = 200 + +// httpGetPRStatusesBatch fetches statuses for multiple PRs in one request. +// Body: {"refs": [{"owner","repo","number"}, ...]}. +// Response: {"statuses": {"/#": }}. PRs that +// fail upstream are omitted rather than failing the whole batch. +func (c *Controller) httpGetPRStatusesBatch(ctx *gin.Context) { + var body struct { + Refs []PRRef `json:"refs"` + } + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + if len(body.Refs) > prStatusesBatchMaxRefs { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "too many refs"}) + return + } + statuses, err := c.service.GetPRStatusesBatch(ctx.Request.Context(), body.Refs) + if err != nil { + c.handleSearchError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"statuses": statuses}) +} + func (c *Controller) httpGetPRInfo(ctx *gin.Context) { owner := ctx.Param("owner") repo := ctx.Param("repo") @@ -344,6 +395,84 @@ func (c *Controller) httpListRepoBranches(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{"branches": branches}) } +// httpSearchUserPRs searches for pull requests. Accepts: +// - query: the complete GitHub search query (e.g. "is:pr author:@me state:open") +// - filter: additional qualifiers appended to the default `type:pr` +// - page: 1-indexed page (default 1) +// - per_page: items per page, clamped to 1..100 (default 50) +// +// When query is non-empty it is used verbatim; otherwise filter is used. +func (c *Controller) httpSearchUserPRs(ctx *gin.Context) { + query := ctx.Query("query") + filter := ctx.Query("filter") + page, perPage := parsePaginationQuery(ctx) + result, err := c.service.SearchUserPRsPaged(ctx.Request.Context(), filter, query, page, perPage) + if err != nil { + c.handleSearchError(ctx, err) + return + } + if result.PRs == nil { + result.PRs = []*PR{} + } + ctx.JSON(http.StatusOK, result) +} + +// httpSearchUserIssues mirrors httpSearchUserPRs for issues. +func (c *Controller) httpSearchUserIssues(ctx *gin.Context) { + query := ctx.Query("query") + filter := ctx.Query("filter") + page, perPage := parsePaginationQuery(ctx) + result, err := c.service.SearchUserIssuesPaged(ctx.Request.Context(), filter, query, page, perPage) + if err != nil { + c.handleSearchError(ctx, err) + return + } + if result.Issues == nil { + result.Issues = []*Issue{} + } + ctx.JSON(http.StatusOK, result) +} + +// parsePaginationQuery reads `page` and `per_page` query params. Missing or +// non-positive values fall back to defaults (page=1, perPage=50). The upper +// bound for perPage (100) is applied later by clampSearchPage in the client +// layer, where it also gates the cache key. +func parsePaginationQuery(ctx *gin.Context) (int, int) { + page, _ := strconv.Atoi(ctx.Query("page")) + perPage, _ := strconv.Atoi(ctx.Query("per_page")) + if page < 1 { + page = 1 + } + if perPage <= 0 { + perPage = 50 + } + return page, perPage +} + +// handleSearchError maps client errors to proper HTTP responses. +func (c *Controller) handleSearchError(ctx *gin.Context, err error) { + if errors.Is(err, ErrNoClient) { + ctx.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "GitHub is not configured. Install the gh CLI and run 'gh auth login', or add a GITHUB_TOKEN secret.", + "code": "github_not_configured", + }) + return + } + status := http.StatusInternalServerError + var apiErr *GitHubAPIError + if errors.As(err, &apiErr) { + switch apiErr.StatusCode { + case http.StatusNotFound: + status = http.StatusNotFound + case http.StatusUnauthorized: + status = http.StatusUnauthorized + case http.StatusForbidden: + status = http.StatusForbidden + } + } + ctx.JSON(status, gin.H{"error": err.Error()}) +} + func (c *Controller) httpGetStats(ctx *gin.Context) { req := &PRStatsRequest{ WorkspaceID: ctx.Query("workspace_id"), diff --git a/apps/backend/internal/github/controller_test.go b/apps/backend/internal/github/controller_test.go index 6dff463892..f778b6b2b0 100644 --- a/apps/backend/internal/github/controller_test.go +++ b/apps/backend/internal/github/controller_test.go @@ -35,6 +35,12 @@ func (s *stubClient) FindPRByBranch(context.Context, string, string, string) (*P func (s *stubClient) ListAuthoredPRs(context.Context, string, string) ([]*PR, error) { return nil, nil } +func (s *stubClient) SearchPRs(context.Context, string, string) ([]*PR, error) { + return nil, nil +} +func (s *stubClient) SearchPRsPaged(context.Context, string, string, int, int) (*PRSearchPage, error) { + return &PRSearchPage{PRs: []*PR{}}, nil +} func (s *stubClient) ListReviewRequestedPRs(context.Context, string, string, string) ([]*PR, error) { return nil, nil } @@ -69,6 +75,9 @@ func (s *stubClient) ListRepoBranches(context.Context, string, string) ([]RepoBr func (s *stubClient) ListIssues(context.Context, string, string) ([]*Issue, error) { return nil, nil } +func (s *stubClient) ListIssuesPaged(context.Context, string, string, int, int) (*IssueSearchPage, error) { + return &IssueSearchPage{Issues: []*Issue{}}, nil +} func (s *stubClient) GetIssueState(context.Context, string, string, int) (string, error) { return defaultPRState, nil } diff --git a/apps/backend/internal/github/gh_client.go b/apps/backend/internal/github/gh_client.go index 6cd071b333..03a95468c0 100644 --- a/apps/backend/internal/github/gh_client.go +++ b/apps/backend/internal/github/gh_client.go @@ -171,21 +171,69 @@ func (c *GHClient) ListReviewRequestedPRs(ctx context.Context, scope, filter, cu return c.parseSearchResults(out) } +func (c *GHClient) SearchPRs(ctx context.Context, filter, customQuery string) ([]*PR, error) { + page, err := c.SearchPRsPaged(ctx, filter, customQuery, 1, 50) + if err != nil { + return nil, err + } + return page.PRs, nil +} + +func (c *GHClient) SearchPRsPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*PRSearchPage, error) { + page, perPage = clampSearchPage(page, perPage) + query := buildPRSearchQuery(filter, customQuery) + out, err := c.run(ctx, "api", "search/issues", + "-X", "GET", + "-f", "q="+query, + "-f", fmt.Sprintf("per_page=%d", perPage), + "-f", fmt.Sprintf("page=%d", page), + ) + if err != nil { + return nil, fmt.Errorf("search PRs: %w", err) + } + var result struct { + TotalCount int `json:"total_count"` + Items json.RawMessage `json:"items"` + } + if err := json.Unmarshal([]byte(out), &result); err != nil { + return nil, fmt.Errorf("parse PR search response: %w", err) + } + prs, err := c.parseSearchResults(string(result.Items)) + if err != nil { + return nil, err + } + return &PRSearchPage{PRs: prs, TotalCount: result.TotalCount, Page: page, PerPage: perPage}, nil +} + func (c *GHClient) ListIssues(ctx context.Context, filter, customQuery string) ([]*Issue, error) { + page, err := c.ListIssuesPaged(ctx, filter, customQuery, 1, 50) + if err != nil { + return nil, err + } + return page.Issues, nil +} + +func (c *GHClient) ListIssuesPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*IssueSearchPage, error) { + page, perPage = clampSearchPage(page, perPage) query := buildIssueSearchQuery(filter, customQuery) out, err := c.run(ctx, "api", "search/issues", "-X", "GET", "-f", "q="+query, - "-f", "per_page=50", - "--jq", ".items") + "-f", fmt.Sprintf("per_page=%d", perPage), + "-f", fmt.Sprintf("page=%d", page), + ) if err != nil { return nil, fmt.Errorf("list issues: %w", err) } - var items []issueSearchItem - if err := json.Unmarshal([]byte(out), &items); err != nil { - return nil, fmt.Errorf("parse issue search results: %w", err) + var result struct { + TotalCount int `json:"total_count"` + Items []issueSearchItem `json:"items"` } - return parseIssueSearchResults(items), nil + if err := json.Unmarshal([]byte(out), &result); err != nil { + return nil, fmt.Errorf("parse issue search response: %w", err) + } + issues := parseIssueSearchResults(result.Items) + return &IssueSearchPage{Issues: issues, TotalCount: result.TotalCount, Page: page, PerPage: perPage}, nil } func (c *GHClient) GetIssueState(ctx context.Context, owner, repo string, number int) (string, error) { diff --git a/apps/backend/internal/github/mock_client.go b/apps/backend/internal/github/mock_client.go index 58053c81e5..6c1a707341 100644 --- a/apps/backend/internal/github/mock_client.go +++ b/apps/backend/internal/github/mock_client.go @@ -137,6 +137,28 @@ func (m *MockClient) ListIssues(context.Context, string, string) ([]*Issue, erro return nil, nil } +func (m *MockClient) ListIssuesPaged(context.Context, string, string, int, int) (*IssueSearchPage, error) { + return &IssueSearchPage{Issues: []*Issue{}, TotalCount: 0, Page: 1, PerPage: 50}, nil +} + +func (m *MockClient) SearchPRs(context.Context, string, string) ([]*PR, error) { + m.mu.RLock() + defer m.mu.RUnlock() + result := make([]*PR, 0, len(m.prs)) + for _, pr := range m.prs { + result = append(result, pr) + } + return result, nil +} + +func (m *MockClient) SearchPRsPaged(ctx context.Context, _, _ string, page, perPage int) (*PRSearchPage, error) { + prs, err := m.SearchPRs(ctx, "", "") + if err != nil { + return nil, err + } + return &PRSearchPage{PRs: prs, TotalCount: len(prs), Page: page, PerPage: perPage}, nil +} + func (m *MockClient) GetIssueState(context.Context, string, string, int) (string, error) { return defaultPRState, nil } diff --git a/apps/backend/internal/github/models.go b/apps/backend/internal/github/models.go index f6f0ee0b70..d17e6ccf11 100644 --- a/apps/backend/internal/github/models.go +++ b/apps/backend/internal/github/models.go @@ -92,6 +92,25 @@ type PRStatus struct { MergeableState string `json:"mergeable_state"` // "clean", "blocked", "behind", "dirty", "has_hooks", "unstable", "draft", "unknown", "" ReviewCount int `json:"review_count"` PendingReviewCount int `json:"pending_review_count"` + ChecksTotal int `json:"checks_total"` + ChecksPassing int `json:"checks_passing"` +} + +// PRSearchPage is a paginated slice of PR search results, with the total +// count reported by the GitHub Search API (capped at 1000). +type PRSearchPage struct { + PRs []*PR `json:"prs"` + TotalCount int `json:"total_count"` + Page int `json:"page"` + PerPage int `json:"per_page"` +} + +// IssueSearchPage is a paginated slice of Issue search results. +type IssueSearchPage struct { + Issues []*Issue `json:"issues"` + TotalCount int `json:"total_count"` + Page int `json:"page"` + PerPage int `json:"per_page"` } // PRWatch tracks active PR monitoring (session → PR). diff --git a/apps/backend/internal/github/noop_client.go b/apps/backend/internal/github/noop_client.go index e5cc67ae2e..d9e0668bc2 100644 --- a/apps/backend/internal/github/noop_client.go +++ b/apps/backend/internal/github/noop_client.go @@ -85,6 +85,18 @@ func (c *NoopClient) ListIssues(context.Context, string, string) ([]*Issue, erro return nil, ErrNoClient } +func (c *NoopClient) ListIssuesPaged(context.Context, string, string, int, int) (*IssueSearchPage, error) { + return nil, ErrNoClient +} + +func (c *NoopClient) SearchPRs(context.Context, string, string) ([]*PR, error) { + return nil, ErrNoClient +} + +func (c *NoopClient) SearchPRsPaged(context.Context, string, string, int, int) (*PRSearchPage, error) { + return nil, ErrNoClient +} + func (c *NoopClient) GetIssueState(context.Context, string, string, int) (string, error) { return "", ErrNoClient } diff --git a/apps/backend/internal/github/pat_client.go b/apps/backend/internal/github/pat_client.go index 62f137b976..8a0726ff2f 100644 --- a/apps/backend/internal/github/pat_client.go +++ b/apps/backend/internal/github/pat_client.go @@ -133,16 +133,59 @@ func (c *PATClient) ListReviewRequestedPRs(ctx context.Context, scope, filter, c return prs, nil } +func (c *PATClient) SearchPRs(ctx context.Context, filter, customQuery string) ([]*PR, error) { + page, err := c.SearchPRsPaged(ctx, filter, customQuery, 1, 50) + if err != nil { + return nil, err + } + return page.PRs, nil +} + +func (c *PATClient) SearchPRsPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*PRSearchPage, error) { + page, perPage = clampSearchPage(page, perPage) + query := buildPRSearchQuery(filter, customQuery) + var result struct { + TotalCount int `json:"total_count"` + Items []patSearchItem `json:"items"` + } + endpoint := fmt.Sprintf("/search/issues?q=%s&per_page=%d&page=%d", + url.QueryEscape(query), perPage, page) + if err := c.get(ctx, endpoint, &result); err != nil { + return nil, fmt.Errorf("search PRs: %w", err) + } + prs := make([]*PR, len(result.Items)) + for i, item := range result.Items { + prs[i] = convertSearchItemToPR( + item.Number, item.Title, item.HTMLURL, item.State, + item.User.Login, item.RepositoryURL, item.Draft, + item.CreatedAt, item.UpdatedAt, + ) + } + return &PRSearchPage{PRs: prs, TotalCount: result.TotalCount, Page: page, PerPage: perPage}, nil +} + func (c *PATClient) ListIssues(ctx context.Context, filter, customQuery string) ([]*Issue, error) { + page, err := c.ListIssuesPaged(ctx, filter, customQuery, 1, 50) + if err != nil { + return nil, err + } + return page.Issues, nil +} + +func (c *PATClient) ListIssuesPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*IssueSearchPage, error) { + page, perPage = clampSearchPage(page, perPage) query := buildIssueSearchQuery(filter, customQuery) var result struct { - Items []issueSearchItem `json:"items"` + TotalCount int `json:"total_count"` + Items []issueSearchItem `json:"items"` } - endpoint := "/search/issues?q=" + url.QueryEscape(query) + "&per_page=50" + endpoint := fmt.Sprintf("/search/issues?q=%s&per_page=%d&page=%d", + url.QueryEscape(query), perPage, page) if err := c.get(ctx, endpoint, &result); err != nil { return nil, fmt.Errorf("search issues: %w", err) } - return parseIssueSearchResults(result.Items), nil + issues := parseIssueSearchResults(result.Items) + return &IssueSearchPage{Issues: issues, TotalCount: result.TotalCount, Page: page, PerPage: perPage}, nil } func (c *PATClient) GetIssueState(ctx context.Context, owner, repo string, number int) (string, error) { diff --git a/apps/backend/internal/github/service.go b/apps/backend/internal/github/service.go index b6a9e38682..77f478dccd 100644 --- a/apps/backend/internal/github/service.go +++ b/apps/backend/internal/github/service.go @@ -48,17 +48,21 @@ type Service struct { taskSessionChecker TaskSessionChecker syncGroup singleflight.Group taskEventSubs []bus.Subscription + searchCache *ttlCache + prStatusCache *ttlCache } // NewService creates a new GitHub service. func NewService(client Client, authMethod string, secrets SecretProvider, store *Store, eventBus bus.EventBus, log *logger.Logger) *Service { return &Service{ - client: client, - authMethod: authMethod, - secrets: secrets, - store: store, - eventBus: eventBus, - logger: log, + client: client, + authMethod: authMethod, + secrets: secrets, + store: store, + eventBus: eventBus, + logger: log, + searchCache: newTTLCache(), + prStatusCache: newTTLCache(), } } @@ -544,6 +548,83 @@ func (s *Service) GetPRFeedback(ctx context.Context, owner, repo string, number return s.client.GetPRFeedback(ctx, owner, repo, number) } +// GetPRStatus fetches lightweight PR status (review + checks + mergeable). +// Cached briefly so repeat loads of the same list (pagination, re-render, +// back-navigation) don't refetch. The returned pointer is shared — callers +// must not mutate it. +func (s *Service) GetPRStatus(ctx context.Context, owner, repo string, number int) (*PRStatus, error) { + if s.client == nil { + return nil, fmt.Errorf("github client not available") + } + key := prStatusCacheKey(owner, repo, number) + v, err := s.prStatusCache.doOrFetch(key, func() (any, error) { + return s.client.GetPRStatus(ctx, owner, repo, number) + }) + if err != nil { + return nil, err + } + return v.(*PRStatus), nil +} + +// PRRef identifies a pull request by owner/repo/number. +type PRRef struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Number int `json:"number"` +} + +// prStatusBatchConcurrency bounds how many upstream GetPRStatus calls run in +// parallel. GitHub has per-hour quotas and per-endpoint concurrency limits; +// this is well under both while still collapsing a 25-PR page into a single +// short wait on the client. +const prStatusBatchConcurrency = 8 + +// GetPRStatusesBatch fetches statuses for multiple PRs concurrently, honoring +// the per-PR cache. The returned map is keyed by prStatusCacheKey; PRs that +// fail to fetch are logged and omitted from the result so one bad repo +// doesn't poison the page. +func (s *Service) GetPRStatusesBatch(ctx context.Context, refs []PRRef) (map[string]*PRStatus, error) { + if s.client == nil { + return nil, fmt.Errorf("github client not available") + } + result := make(map[string]*PRStatus, len(refs)) + var mu sync.Mutex + sem := make(chan struct{}, prStatusBatchConcurrency) + var wg sync.WaitGroup + for _, ref := range refs { + if ref.Owner == "" || ref.Repo == "" || ref.Number <= 0 { + continue + } + wg.Add(1) + go func(r PRRef) { + defer wg.Done() + // Release queued goroutines early when the caller disconnects — + // otherwise up to 200 refs queue up serially behind the semaphore + // and each still runs its full upstream fetch. + select { + case sem <- struct{}{}: + case <-ctx.Done(): + return + } + defer func() { <-sem }() + status, err := s.GetPRStatus(ctx, r.Owner, r.Repo, r.Number) + if err != nil { + s.logger.Debug("batch PR status fetch failed", + zap.String("owner", r.Owner), + zap.String("repo", r.Repo), + zap.Int("number", r.Number), + zap.Error(err)) + return + } + mu.Lock() + result[prStatusCacheKey(r.Owner, r.Repo, r.Number)] = status + mu.Unlock() + }(ref) + } + wg.Wait() + return result, nil +} + // TriggerPRSync performs an immediate PR status sync for a task. // If the watch has a PR number, it fetches the latest status from GitHub // and syncs it to the TaskPR record. If still searching (pr_number=0), @@ -968,6 +1049,78 @@ func (s *Service) ListRepoBranches(ctx context.Context, owner, repo string) ([]R return s.client.ListRepoBranches(ctx, owner, repo) } +// SearchUserPRs searches for PRs using a filter or custom query. Unless the +// caller already pins a type qualifier, `type:pr` is injected into the +// composed query. +func (s *Service) SearchUserPRs(ctx context.Context, filter, customQuery string) ([]*PR, error) { + if s.client == nil { + return nil, fmt.Errorf("github client not available") + } + return s.client.SearchPRs(ctx, filter, customQuery) +} + +// SearchUserIssues searches for issues using a filter or custom query. Unless +// the caller already pins a type qualifier, `type:issue` is injected into the +// composed query. +func (s *Service) SearchUserIssues(ctx context.Context, filter, customQuery string) ([]*Issue, error) { + if s.client == nil { + return nil, fmt.Errorf("github client not available") + } + return s.client.ListIssues(ctx, filter, customQuery) +} + +// SearchUserPRsPaged is the paginated variant of SearchUserPRs. Results are +// cached for a short window (see searchCacheTTL) — callers must not mutate +// the returned page, since it is shared across concurrent requests. +func (s *Service) SearchUserPRsPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*PRSearchPage, error) { + if s.client == nil { + return nil, fmt.Errorf("github client not available") + } + // Clamp before composing the cache key so perPage=150 and perPage=100 + // share the same cached page instead of creating two entries for + // identical results. + page, perPage = clampSearchPage(page, perPage) + key := searchCacheKey("pr", filter, customQuery, page, perPage) + v, err := s.searchCache.doOrFetch(key, func() (any, error) { + result, err := s.client.SearchPRsPaged(ctx, filter, customQuery, page, perPage) + if err != nil { + return nil, err + } + if result != nil && result.PRs == nil { + result.PRs = []*PR{} + } + return result, nil + }) + if err != nil { + return nil, err + } + return v.(*PRSearchPage), nil +} + +// SearchUserIssuesPaged is the paginated variant of SearchUserIssues. See +// SearchUserPRsPaged for caching semantics. +func (s *Service) SearchUserIssuesPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*IssueSearchPage, error) { + if s.client == nil { + return nil, fmt.Errorf("github client not available") + } + page, perPage = clampSearchPage(page, perPage) + key := searchCacheKey("issue", filter, customQuery, page, perPage) + v, err := s.searchCache.doOrFetch(key, func() (any, error) { + result, err := s.client.ListIssuesPaged(ctx, filter, customQuery, page, perPage) + if err != nil { + return nil, err + } + if result != nil && result.Issues == nil { + result.Issues = []*Issue{} + } + return result, nil + }) + if err != nil { + return nil, err + } + return v.(*IssueSearchPage), nil +} + // ReserveReviewPRTask atomically claims the dedup slot for a (watch, repo, PR) // tuple before task creation begins. Returns true if this caller won and // should proceed to create the task, false if another caller already holds @@ -1319,9 +1472,12 @@ func (s *Service) fetchIssues(ctx context.Context, watch *IssueWatch) ([]*Issue, return s.fetchIssuesWithRepoFilter(ctx, watch), nil } -// buildIssueFilter builds the filter qualifier from watch labels. +// buildIssueFilter builds the filter qualifier from watch labels. `state:open` +// is included because the watcher is only interested in active issues — +// buildIssueSearchQuery no longer injects it (the /github page presets +// supply their own state qualifiers), so we add it here instead. func (s *Service) buildIssueFilter(watch *IssueWatch) string { - var parts []string + parts := []string{"state:open"} for _, label := range watch.Labels { if strings.ContainsRune(label, ' ') { parts = append(parts, `label:"`+label+`"`) diff --git a/apps/backend/internal/github/service_issue_test.go b/apps/backend/internal/github/service_issue_test.go index fd1b74de6d..cfa89bf9b7 100644 --- a/apps/backend/internal/github/service_issue_test.go +++ b/apps/backend/internal/github/service_issue_test.go @@ -89,3 +89,35 @@ func TestShouldDeleteIssueTask(t *testing.T) { } }) } + +func TestBuildIssueFilter_IncludesStateOpen(t *testing.T) { + svc := &Service{} + tests := []struct { + name string + watch *IssueWatch + want string + }{ + { + name: "no labels", + watch: &IssueWatch{}, + want: "state:open", + }, + { + name: "single label", + watch: &IssueWatch{Labels: []string{"bug"}}, + want: "state:open label:bug", + }, + { + name: "label with space is quoted", + watch: &IssueWatch{Labels: []string{"good first issue"}}, + want: `state:open label:"good first issue"`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := svc.buildIssueFilter(tt.watch); got != tt.want { + t.Errorf("buildIssueFilter() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/apps/backend/internal/github/service_pr_status_test.go b/apps/backend/internal/github/service_pr_status_test.go new file mode 100644 index 0000000000..18e7a7f125 --- /dev/null +++ b/apps/backend/internal/github/service_pr_status_test.go @@ -0,0 +1,122 @@ +package github + +import ( + "context" + "fmt" + "sync/atomic" + "testing" +) + +type countingStatusClient struct { + *MockClient + calls atomic.Int32 +} + +func (c *countingStatusClient) GetPRStatus(_ context.Context, owner, repo string, number int) (*PRStatus, error) { + c.calls.Add(1) + return &PRStatus{PR: &PR{RepoOwner: owner, RepoName: repo, Number: number}}, nil +} + +func TestService_GetPRStatus_UsesCache(t *testing.T) { + client := &countingStatusClient{MockClient: NewMockClient()} + svc := newTestService(client) + ctx := context.Background() + + for i := 0; i < 3; i++ { + if _, err := svc.GetPRStatus(ctx, "o", "r", 1); err != nil { + t.Fatalf("call %d: %v", i, err) + } + } + if got := client.calls.Load(); got != 1 { + t.Fatalf("expected 1 upstream call, got %d", got) + } + + if _, err := svc.GetPRStatus(ctx, "o", "r", 2); err != nil { + t.Fatal(err) + } + if got := client.calls.Load(); got != 2 { + t.Fatalf("expected new fetch for different number, got %d", got) + } +} + +func TestService_GetPRStatusesBatch_FetchesAndCaches(t *testing.T) { + client := &countingStatusClient{MockClient: NewMockClient()} + svc := newTestService(client) + ctx := context.Background() + + refs := []PRRef{ + {Owner: "o", Repo: "r", Number: 1}, + {Owner: "o", Repo: "r", Number: 2}, + {Owner: "o", Repo: "r", Number: 3}, + } + statuses, err := svc.GetPRStatusesBatch(ctx, refs) + if err != nil { + t.Fatal(err) + } + if len(statuses) != 3 { + t.Fatalf("expected 3 statuses, got %d", len(statuses)) + } + if got := client.calls.Load(); got != 3 { + t.Fatalf("expected 3 upstream calls, got %d", got) + } + + // Second batch hits the per-PR cache — no new upstream calls. + if _, err := svc.GetPRStatusesBatch(ctx, refs); err != nil { + t.Fatal(err) + } + if got := client.calls.Load(); got != 3 { + t.Fatalf("expected no new upstream calls on 2nd batch, got %d", got) + } +} + +func TestService_GetPRStatusesBatch_SkipsInvalidRefs(t *testing.T) { + client := &countingStatusClient{MockClient: NewMockClient()} + svc := newTestService(client) + refs := []PRRef{ + {Owner: "", Repo: "r", Number: 1}, + {Owner: "o", Repo: "", Number: 2}, + {Owner: "o", Repo: "r", Number: 0}, + {Owner: "o", Repo: "r", Number: 7}, + } + statuses, err := svc.GetPRStatusesBatch(context.Background(), refs) + if err != nil { + t.Fatal(err) + } + if len(statuses) != 1 { + t.Fatalf("expected 1 valid status, got %d", len(statuses)) + } + if _, ok := statuses["o/r#7"]; !ok { + t.Fatalf("expected status for valid ref, got keys %v", keys(statuses)) + } +} + +type failingStatusClient struct { + *MockClient +} + +func (failingStatusClient) GetPRStatus(context.Context, string, string, int) (*PRStatus, error) { + return nil, fmt.Errorf("upstream boom") +} + +func TestService_GetPRStatusesBatch_OmitsFailedRefs(t *testing.T) { + svc := newTestService(failingStatusClient{MockClient: NewMockClient()}) + refs := []PRRef{ + {Owner: "o", Repo: "r", Number: 1}, + {Owner: "o", Repo: "r", Number: 2}, + } + statuses, err := svc.GetPRStatusesBatch(context.Background(), refs) + if err != nil { + t.Fatalf("unexpected batch error: %v", err) + } + if len(statuses) != 0 { + t.Fatalf("expected empty map when all refs fail, got %d", len(statuses)) + } +} + +func keys[K comparable, V any](m map[K]V) []K { + out := make([]K, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/apps/backend/internal/github/service_search_cache_test.go b/apps/backend/internal/github/service_search_cache_test.go new file mode 100644 index 0000000000..e37ed365ad --- /dev/null +++ b/apps/backend/internal/github/service_search_cache_test.go @@ -0,0 +1,91 @@ +package github + +import ( + "context" + "sync/atomic" + "testing" + + "github.com/kandev/kandev/internal/common/logger" +) + +type countingSearchClient struct { + *MockClient + prCalls atomic.Int32 + issueCalls atomic.Int32 +} + +func (c *countingSearchClient) SearchPRsPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*PRSearchPage, error) { + c.prCalls.Add(1) + return c.MockClient.SearchPRsPaged(ctx, filter, customQuery, page, perPage) +} + +func (c *countingSearchClient) ListIssuesPaged(ctx context.Context, filter, customQuery string, page, perPage int) (*IssueSearchPage, error) { + c.issueCalls.Add(1) + return c.MockClient.ListIssuesPaged(ctx, filter, customQuery, page, perPage) +} + +func newTestService(client Client) *Service { + return &Service{ + client: client, + authMethod: AuthMethodPAT, + logger: logger.Default(), + searchCache: newTTLCache(), + prStatusCache: newTTLCache(), + } +} + +func TestService_SearchUserPRsPaged_UsesCache(t *testing.T) { + client := &countingSearchClient{MockClient: NewMockClient()} + svc := newTestService(client) + ctx := context.Background() + + for i := 0; i < 3; i++ { + if _, err := svc.SearchUserPRsPaged(ctx, "", "is:open author:@me", 1, 25); err != nil { + t.Fatalf("call %d: %v", i, err) + } + } + if got := client.prCalls.Load(); got != 1 { + t.Fatalf("expected 1 upstream PR call, got %d", got) + } + + if _, err := svc.SearchUserPRsPaged(ctx, "", "is:open author:@me", 2, 25); err != nil { + t.Fatal(err) + } + if got := client.prCalls.Load(); got != 2 { + t.Fatalf("expected new fetch for different page, got %d", got) + } +} + +func TestService_SearchUserIssuesPaged_UsesCache(t *testing.T) { + client := &countingSearchClient{MockClient: NewMockClient()} + svc := newTestService(client) + ctx := context.Background() + + for i := 0; i < 3; i++ { + if _, err := svc.SearchUserIssuesPaged(ctx, "", "is:open", 1, 25); err != nil { + t.Fatalf("call %d: %v", i, err) + } + } + if got := client.issueCalls.Load(); got != 1 { + t.Fatalf("expected 1 upstream issue call, got %d", got) + } +} + +func TestService_SearchUserPRsPaged_PRsIssuesIsolated(t *testing.T) { + client := &countingSearchClient{MockClient: NewMockClient()} + svc := newTestService(client) + ctx := context.Background() + + if _, err := svc.SearchUserPRsPaged(ctx, "", "q", 1, 25); err != nil { + t.Fatal(err) + } + if _, err := svc.SearchUserIssuesPaged(ctx, "", "q", 1, 25); err != nil { + t.Fatal(err) + } + if got := client.prCalls.Load(); got != 1 { + t.Fatalf("pr calls = %d", got) + } + if got := client.issueCalls.Load(); got != 1 { + t.Fatalf("issue calls = %d", got) + } +} diff --git a/apps/backend/internal/github/ttl_cache.go b/apps/backend/internal/github/ttl_cache.go new file mode 100644 index 0000000000..41f02cdc38 --- /dev/null +++ b/apps/backend/internal/github/ttl_cache.go @@ -0,0 +1,129 @@ +package github + +import ( + "fmt" + "sync" + "time" + + "golang.org/x/sync/singleflight" +) + +// Short-TTL cache around GitHub responses. The gh CLI subprocess path plus +// multi-step status builds make round trips expensive; caching within a brief +// window keeps preset switching, pagination, and list re-renders snappy. +const ( + defaultCacheTTL = 30 * time.Second + defaultCacheMaxSize = 200 +) + +type ttlEntry struct { + value any + expiresAt time.Time +} + +// ttlCache is a tiny TTL map guarded by singleflight to coalesce concurrent +// misses for the same key. When size exceeds the cap, entries with the +// earliest expiry are dropped — good enough for a sub-minute window. +type ttlCache struct { + mu sync.Mutex + entries map[string]ttlEntry + sf singleflight.Group + ttl time.Duration + maxSize int + now func() time.Time +} + +func newTTLCache() *ttlCache { + return &ttlCache{ + entries: make(map[string]ttlEntry), + ttl: defaultCacheTTL, + maxSize: defaultCacheMaxSize, + now: time.Now, + } +} + +func (c *ttlCache) get(key string) (any, bool) { + c.mu.Lock() + defer c.mu.Unlock() + entry, ok := c.entries[key] + if !ok { + return nil, false + } + if c.now().After(entry.expiresAt) { + delete(c.entries, key) + return nil, false + } + return entry.value, true +} + +func (c *ttlCache) set(key string, value any) { + c.mu.Lock() + defer c.mu.Unlock() + if len(c.entries) >= c.maxSize { + c.evictLocked() + } + c.entries[key] = ttlEntry{value: value, expiresAt: c.now().Add(c.ttl)} +} + +// evictLocked first drops expired entries; if still over the cap, drops the +// entries with the earliest expiry until back under the limit. Caller must +// hold c.mu. +func (c *ttlCache) evictLocked() { + now := c.now() + for k, e := range c.entries { + if now.After(e.expiresAt) { + delete(c.entries, k) + } + } + if len(c.entries) < c.maxSize { + return + } + var oldestKey string + var oldestExp time.Time + for len(c.entries) >= c.maxSize { + oldestKey = "" + for k, e := range c.entries { + if oldestKey == "" || e.expiresAt.Before(oldestExp) { + oldestKey = k + oldestExp = e.expiresAt + } + } + if oldestKey == "" { + return + } + delete(c.entries, oldestKey) + } +} + +// doOrFetch returns a cached value when fresh; otherwise runs fetch under a +// singleflight guard, caches the result, and returns it. Errors are not +// cached. The returned value is shared — callers must not mutate it. +func (c *ttlCache) doOrFetch(key string, fetch func() (any, error)) (any, error) { + if v, ok := c.get(key); ok { + return v, nil + } + v, err, _ := c.sf.Do(key, func() (any, error) { + if v, ok := c.get(key); ok { + return v, nil + } + v, err := fetch() + if err != nil { + return nil, err + } + c.set(key, v) + return v, nil + }) + return v, err +} + +// searchCacheKey composes a cache key with length-prefixed string fields so +// that user-controllable inputs (e.g. customQuery) cannot collide with other +// keys by embedding the separator. +func searchCacheKey(kind, filter, customQuery string, page, perPage int) string { + return fmt.Sprintf("%d:%s|%d:%s|%d:%s|%d|%d", + len(kind), kind, len(filter), filter, len(customQuery), customQuery, page, perPage) +} + +func prStatusCacheKey(owner, repo string, number int) string { + return fmt.Sprintf("%s/%s#%d", owner, repo, number) +} diff --git a/apps/backend/internal/github/ttl_cache_test.go b/apps/backend/internal/github/ttl_cache_test.go new file mode 100644 index 0000000000..144ac1ff8e --- /dev/null +++ b/apps/backend/internal/github/ttl_cache_test.go @@ -0,0 +1,161 @@ +package github + +import ( + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "testing/synctest" + "time" +) + +func TestTTLCache_HitAndMiss(t *testing.T) { + cache := newTTLCache() + var calls int + fetch := func() (any, error) { + calls++ + return "result", nil + } + for i := 0; i < 3; i++ { + v, err := cache.doOrFetch("k", fetch) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if v != "result" { + t.Fatalf("unexpected value: %v", v) + } + } + if calls != 1 { + t.Fatalf("expected fetch called once, got %d", calls) + } +} + +func TestTTLCache_Expiry(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + cache := newTTLCache() + cache.ttl = 30 * time.Second + var calls int + fetch := func() (any, error) { + calls++ + return calls, nil + } + if _, err := cache.doOrFetch("k", fetch); err != nil { + t.Fatal(err) + } + time.Sleep(29 * time.Second) + if _, err := cache.doOrFetch("k", fetch); err != nil { + t.Fatal(err) + } + if calls != 1 { + t.Fatalf("expected cache hit before TTL; calls=%d", calls) + } + time.Sleep(2 * time.Second) + v, err := cache.doOrFetch("k", fetch) + if err != nil { + t.Fatal(err) + } + if calls != 2 { + t.Fatalf("expected refetch after TTL; calls=%d", calls) + } + if v != 2 { + t.Fatalf("expected fresh value 2, got %v", v) + } + }) +} + +func TestTTLCache_ErrorNotCached(t *testing.T) { + cache := newTTLCache() + var calls int + sentinel := errors.New("boom") + fetch := func() (any, error) { + calls++ + if calls == 1 { + return nil, sentinel + } + return "ok", nil + } + if _, err := cache.doOrFetch("k", fetch); !errors.Is(err, sentinel) { + t.Fatalf("expected sentinel error, got %v", err) + } + v, err := cache.doOrFetch("k", fetch) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if v != "ok" { + t.Fatalf("unexpected value: %v", v) + } + if calls != 2 { + t.Fatalf("expected 2 fetch calls (error not cached), got %d", calls) + } +} + +func TestSearchCacheKey_KeyIsolation(t *testing.T) { + if searchCacheKey("pr", "", "is:open", 1, 25) == searchCacheKey("issue", "", "is:open", 1, 25) { + t.Fatal("kind should be part of key") + } + if searchCacheKey("pr", "", "is:open", 1, 25) == searchCacheKey("pr", "", "is:open", 2, 25) { + t.Fatal("page should be part of key") + } + if searchCacheKey("pr", "", "is:open", 1, 25) == searchCacheKey("pr", "", "is:closed", 1, 25) { + t.Fatal("customQuery should be part of key") + } + // A user-controllable customQuery must not be able to collide with + // adjacent fields by embedding the separator. + collisions := [][2][5]any{ + {{"pr", "a", "b", 1, 25}, {"pr", "a|b", "", 1, 25}}, + {{"pr", "", "a|b|1|25", 0, 0}, {"pr", "a", "b", 1, 25}}, + } + for _, c := range collisions { + a := searchCacheKey(c[0][0].(string), c[0][1].(string), c[0][2].(string), c[0][3].(int), c[0][4].(int)) + b := searchCacheKey(c[1][0].(string), c[1][1].(string), c[1][2].(string), c[1][3].(int), c[1][4].(int)) + if a == b { + t.Fatalf("separator collision: %q == %q", a, b) + } + } +} + +func TestTTLCache_Singleflight(t *testing.T) { + cache := newTTLCache() + var calls atomic.Int32 + release := make(chan struct{}) + started := make(chan struct{}, 1) + fetch := func() (any, error) { + calls.Add(1) + select { + case started <- struct{}{}: + default: + } + <-release + return "v", nil + } + + const n = 8 + var wg sync.WaitGroup + wg.Add(n) + for i := 0; i < n; i++ { + go func() { + defer wg.Done() + if _, err := cache.doOrFetch("k", fetch); err != nil { + t.Errorf("fetch err: %v", err) + } + }() + } + <-started + close(release) + wg.Wait() + if got := calls.Load(); got != 1 { + t.Fatalf("expected singleflight to coalesce; calls=%d", got) + } +} + +func TestTTLCache_MaxSizeEviction(t *testing.T) { + cache := newTTLCache() + cache.maxSize = 3 + for i := 0; i < 5; i++ { + cache.set(fmt.Sprintf("k%d", i), i) + } + if len(cache.entries) > cache.maxSize { + t.Fatalf("cache exceeded maxSize: %d", len(cache.entries)) + } +} diff --git a/apps/web/app/github/github-page-client.tsx b/apps/web/app/github/github-page-client.tsx new file mode 100644 index 0000000000..bae37315e8 --- /dev/null +++ b/apps/web/app/github/github-page-client.tsx @@ -0,0 +1,352 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { IconArrowLeft, IconBrandGithub } from "@tabler/icons-react"; +import { Alert, AlertDescription } from "@kandev/ui/alert"; +import { useGitHubStatus } from "@/hooks/domains/github/use-github-status"; +import type { Repository, Workflow, WorkflowStep } from "@/lib/types/http"; +import type { GitHubIssue, GitHubPR } from "@/lib/types/github"; +import { PRList } from "@/components/github/my-github/pr-list"; +import { IssueList } from "@/components/github/my-github/issue-list"; +import { + PresetsSidebar, + type SidebarSelection, +} from "@/components/github/my-github/presets-sidebar"; +import { PR_PRESETS, ISSUE_PRESETS } from "@/components/github/my-github/search-bar"; +import { useGitHubSearch } from "@/components/github/my-github/use-github-search"; +import { useSavedPresets, type SavedPreset } from "@/components/github/my-github/use-saved-presets"; +import { useKnownRepos, resetKnownReposStore } from "@/components/github/my-github/use-known-repos"; +import { useCommittedQuery } from "@/components/github/my-github/use-committed-query"; +import { ListToolbar } from "@/components/github/my-github/list-toolbar"; +import { ResultsPagination } from "@/components/github/my-github/results-pagination"; +import { SavePresetDialog } from "@/components/github/my-github/save-preset-dialog"; +import { + QuickTaskLauncher, + type LaunchPayload, +} from "@/components/github/my-github/quick-task-launcher"; + +type GitHubPageClientProps = { + workspaceId?: string; + workflows: Workflow[]; + steps: WorkflowStep[]; + repositories: Repository[]; +}; + +function PageHeader() { + return ( +
+ + + + +

My GitHub

+ + Pull requests and issues across your repos. + +
+ ); +} + +function NotAuthenticatedNotice() { + return ( + + + GitHub is not connected. Configure GitHub authentication (gh CLI or a Personal Access Token) + in{" "} + + Settings → GitHub + {" "} + to see your pull requests and issues. + + + ); +} + +function NoWorkspaceNotice() { + return ( +
+ + + No workspace configured. Create a workspace first to start tasks from PRs/issues. + + +
+ ); +} + +function resolveTitle(selection: SidebarSelection, saved: SavedPreset[]): string { + if (selection.source === "saved") { + return saved.find((p) => p.id === selection.id)?.label ?? "Saved query"; + } + const presets = selection.kind === "pr" ? PR_PRESETS : ISSUE_PRESETS; + return ( + presets.find((p) => p.value === selection.id)?.label ?? + (selection.kind === "pr" ? "Pull requests" : "Issues") + ); +} + +function ResultsList({ + selection, + items, + loading, + error, + onStartTask, +}: { + selection: SidebarSelection; + items: Array; + loading: boolean; + error: string | null; + onStartTask: (payload: LaunchPayload) => void; +}) { + if (selection.kind === "pr") { + return ( + + ); + } + return ( + + ); +} + +function defaultSelection(kind: "pr" | "issue"): SidebarSelection { + const presets = kind === "pr" ? PR_PRESETS : ISSUE_PRESETS; + return { kind, source: "preset", id: presets[0]?.value ?? "" }; +} + +function defaultQuery(kind: "pr" | "issue"): string { + const presets = kind === "pr" ? PR_PRESETS : ISSUE_PRESETS; + return presets[0]?.filter ?? ""; +} + +type GitHubPageState = ReturnType; + +function useRepoOptions( + selection: SidebarSelection, + committedQuery: string, + items: Array, + repoFilter: string, +): string[] { + const pageRepos = useMemo( + () => + items + .filter((it) => it.repo_owner && it.repo_name) + .map((it) => `${it.repo_owner}/${it.repo_name}`), + [items], + ); + // Reset the accumulator whenever the query context changes (preset, saved, + // custom query). Repo filter is deliberately excluded so narrowing doesn't + // reset — that's the whole point of the accumulator. + const reposResetKey = `${selection.kind}:${selection.source}:${selection.id}:${committedQuery.trim()}`; + const knownRepos = useKnownRepos(reposResetKey, pageRepos); + return useMemo(() => { + const set = new Set(knownRepos); + if (repoFilter) set.add(repoFilter); + return Array.from(set).sort(); + }, [knownRepos, repoFilter]); +} + +function useGitHubPageState() { + const [selection, setSelection] = useState(() => defaultSelection("pr")); + const { + draft: customQuery, + committed: committedQuery, + setDraft: setCustomQuery, + setImmediate: setQueryImmediate, + commit: commitCustomQuery, + } = useCommittedQuery(defaultQuery("pr")); + const [repoFilter, setRepoFilter] = useState(""); + const [saveDialogOpen, setSaveDialogOpen] = useState(false); + const { + presets: savedPresets, + save: saveSavedPreset, + remove: removeSavedPreset, + } = useSavedPresets(); + + const presets = selection.kind === "pr" ? PR_PRESETS : ISSUE_PRESETS; + const search = useGitHubSearch( + selection.kind, + presets, + selection.source === "preset" ? selection.id : "", + committedQuery, + repoFilter, + ); + const repoOptions = useRepoOptions(selection, committedQuery, search.items, repoFilter); + const title = useMemo(() => resolveTitle(selection, savedPresets), [selection, savedPresets]); + + const onSelect = useCallback( + (s: SidebarSelection) => { + setSelection(s); + if (s.source === "saved") { + const found = savedPresets.find((p) => p.id === s.id); + setQueryImmediate(found?.customQuery ?? ""); + setRepoFilter(found?.repoFilter ?? ""); + return; + } + const presetList = s.kind === "pr" ? PR_PRESETS : ISSUE_PRESETS; + const preset = presetList.find((p) => p.value === s.id); + setQueryImmediate(preset?.filter ?? ""); + setRepoFilter(""); + }, + [savedPresets, setQueryImmediate], + ); + + const canSaveCurrent = customQuery.trim().length > 0 || repoFilter.length > 0; + const suggestedLabel = customQuery.trim() || (repoFilter ? `In ${repoFilter}` : "Saved query"); + const onOpenSaveDialog = () => { + if (canSaveCurrent) setSaveDialogOpen(true); + }; + const onConfirmSave = (label: string) => { + const created = saveSavedPreset({ kind: selection.kind, label, customQuery, repoFilter }); + setSelection({ kind: selection.kind, source: "saved", id: created.id }); + }; + const onDeleteSaved = (id: string) => { + removeSavedPreset(id); + if (selection.source === "saved" && selection.id === id) { + setSelection(defaultSelection(selection.kind)); + setQueryImmediate(defaultQuery(selection.kind)); + setRepoFilter(""); + } + }; + + return { + selection, + customQuery, + committedQuery, + setCustomQuery, + commitCustomQuery, + repoFilter, + setRepoFilter, + savedPresets, + search, + repoOptions, + title, + onSelect, + canSaveCurrent, + suggestedLabel, + saveDialogOpen, + setSaveDialogOpen, + onOpenSaveDialog, + onConfirmSave, + onDeleteSaved, + }; +} + +function AuthenticatedLayout({ + workspaceId, + state, + onStartTask, +}: { + workspaceId: string | undefined; + state: GitHubPageState; + onStartTask: (payload: LaunchPayload) => void; +}) { + const { selection, search, repoOptions, title } = state; + return ( +
+ +
+ + {!workspaceId && } +
+ +
+ +
+
+ ); +} + +export function GitHubPageClient({ + workspaceId, + workflows, + steps, + repositories, +}: GitHubPageClientProps) { + const { status, loaded } = useGitHubStatus(); + const [launchPayload, setLaunchPayload] = useState(null); + const state = useGitHubPageState(); + + // Drop the module-level repo accumulator on page unmount so a later visit + // doesn't inherit a stale set from the previous navigation. + useEffect(() => resetKnownReposStore, []); + + const onStartTask = useCallback((payload: LaunchPayload) => setLaunchPayload(payload), []); + const onCloseLaunch = useCallback(() => setLaunchPayload(null), []); + const authed = !!status?.authenticated; + + return ( +
+ + {!loaded &&
Checking GitHub status…
} + {loaded && !authed && ( +
+ +
+ )} + {loaded && authed && ( + + )} + + +
+ ); +} diff --git a/apps/web/app/github/page.tsx b/apps/web/app/github/page.tsx new file mode 100644 index 0000000000..c0630c1e66 --- /dev/null +++ b/apps/web/app/github/page.tsx @@ -0,0 +1,91 @@ +import { + listWorkspacesAction, + listWorkflowsAction, + listRepositoriesAction, + listWorkspaceWorkflowStepsAction, +} from "@/app/actions/workspaces"; +import { fetchUserSettings } from "@/lib/api"; +import { StateHydrator } from "@/components/state-hydrator"; +import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; +import { GitHubPageClient } from "./github-page-client"; +import type { + Workflow, + WorkflowStep, + Repository, + Workspace, + UserSettingsResponse, +} from "@/lib/types/http"; +import type { AppState } from "@/lib/state/store"; + +export default async function GitHubPage() { + let workspaces: Workspace[] = []; + let workflows: Workflow[] = []; + let steps: WorkflowStep[] = []; + let repositories: Repository[] = []; + let workspaceId: string | undefined; + let workspaceDataLoaded = false; + let userSettingsResponse: UserSettingsResponse | null = null; + + try { + const [workspacesResponse, settingsResponse] = await Promise.all([ + listWorkspacesAction(), + fetchUserSettings({ cache: "no-store" }).catch(() => null), + ]); + workspaces = workspacesResponse.workspaces; + userSettingsResponse = settingsResponse; + workspaceId = settingsResponse?.settings?.workspace_id || workspaces[0]?.id; + + if (workspaceId) { + const [workflowsRes, reposRes, stepsRes] = await Promise.all([ + listWorkflowsAction(workspaceId), + listRepositoriesAction(workspaceId), + listWorkspaceWorkflowStepsAction(workspaceId), + ]); + workflows = workflowsRes.workflows; + repositories = reposRes.repositories; + steps = stepsRes.steps; + workspaceDataLoaded = true; + } + } catch (error) { + console.error("Failed to load GitHub page data:", error); + } + + const mappedUserSettings = mapUserSettingsResponse(userSettingsResponse); + + const initialState: Partial = { + workspaces: { items: workspaces, activeId: workspaceId ?? null }, + workflows: { + items: workflows.map((w) => ({ + id: w.id, + workspaceId: w.workspace_id, + name: w.name, + description: w.description ?? null, + sortOrder: w.sort_order ?? 0, + ...(w.agent_profile_id ? { agent_profile_id: w.agent_profile_id } : {}), + })), + activeId: workflows[0]?.id ?? null, + }, + userSettings: { ...mappedUserSettings, workspaceId: workspaceId ?? null }, + ...(workspaceId && workspaceDataLoaded + ? { + repositories: { + itemsByWorkspaceId: { [workspaceId]: repositories }, + loadingByWorkspaceId: { [workspaceId]: false }, + loadedByWorkspaceId: { [workspaceId]: true }, + }, + } + : {}), + }; + + return ( + <> + + + + ); +} diff --git a/apps/web/components/github/my-github/issue-list.tsx b/apps/web/components/github/my-github/issue-list.tsx new file mode 100644 index 0000000000..1cdebf229e --- /dev/null +++ b/apps/web/components/github/my-github/issue-list.tsx @@ -0,0 +1,160 @@ +"use client"; + +import Link from "next/link"; +import { IconCircle, IconCircleCheck, IconPlus, IconChevronDown } from "@tabler/icons-react"; +import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; +import { Spinner } from "@kandev/ui/spinner"; +import { cn } from "@/lib/utils"; +import { formatRelativeTime } from "@/lib/utils"; +import type { GitHubIssue } from "@/lib/types/github"; +import { ISSUE_TASK_PRESETS, type LaunchPayload, type TaskPreset } from "./quick-task-launcher"; + +type IssueListProps = { + items: GitHubIssue[]; + loading: boolean; + error: string | null; + onStartTask: (payload: LaunchPayload) => void; +}; + +function StartTaskMenu({ + issue, + onStartTask, +}: { + issue: GitHubIssue; + onStartTask: IssueListProps["onStartTask"]; +}) { + const launch = (preset: TaskPreset) => onStartTask({ kind: "issue", issue, preset }); + return ( + + + + + + {ISSUE_TASK_PRESETS.map((p) => { + const ItemIcon = p.icon; + return ( + launch(p)} + > + +
+ {p.label} + {p.hint} +
+
+ ); + })} +
+
+ ); +} + +function IssueLabels({ labels }: { labels: string[] }) { + if (!labels?.length) return null; + return ( + <> + {labels.slice(0, 4).map((l) => ( + + {l} + + ))} + {labels.length > 4 && ( + +{labels.length - 4} + )} + + ); +} + +function IssueRow({ + issue, + onStartTask, +}: { + issue: GitHubIssue; + onStartTask: IssueListProps["onStartTask"]; +}) { + const StateIcon = issue.state === "open" ? IconCircle : IconCircleCheck; + const stateClass = + issue.state === "open" + ? "text-emerald-600 dark:text-emerald-400" + : "text-purple-600 dark:text-purple-400"; + return ( +
+ +
+ + {issue.title} + +
+ + {issue.repo_owner}/{issue.repo_name}#{issue.number} + + · + + by {issue.author_login} · opened {formatRelativeTime(issue.created_at)} + + +
+
+
+ +
+
+ ); +} + +function IssueListBody({ loading, error, items, onStartTask }: IssueListProps) { + if (loading) { + return ( +
+ +
+ ); + } + if (error) { + return
{error}
; + } + if (items.length === 0) { + return ( +
+ No issues match this filter. +
+ ); + } + return ( +
+ {items.map((issue) => ( + + ))} +
+ ); +} + +export function IssueList(props: IssueListProps) { + return ( +
+ +
+ ); +} diff --git a/apps/web/components/github/my-github/list-toolbar.tsx b/apps/web/components/github/my-github/list-toolbar.tsx new file mode 100644 index 0000000000..88e928e22c --- /dev/null +++ b/apps/web/components/github/my-github/list-toolbar.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { IconRefresh } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Input } from "@kandev/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { cn } from "@/lib/utils"; +import { formatRelativeTime } from "@/lib/utils"; + +const ALL_REPOS = "__all__"; + +type ListToolbarProps = { + title: string; + count: number; + loading: boolean; + lastFetchedAt: Date | null; + customQuery: string; + committedQuery: string; + onCustomQueryChange: (value: string) => void; + onCommitCustomQuery: () => void; + repoFilter: string; + onRepoFilterChange: (value: string) => void; + repoOptions: string[]; + onRefresh: () => void; +}; + +export function ListToolbar({ + title, + count, + loading, + lastFetchedAt, + customQuery, + committedQuery, + onCustomQueryChange, + onCommitCustomQuery, + repoFilter, + onRepoFilterChange, + repoOptions, + onRefresh, +}: ListToolbarProps) { + const selectValue = repoFilter || ALL_REPOS; + const dirty = customQuery !== committedQuery; + return ( +
+
+

{title}

+ {loading ? "…" : count} +
+ +
+ onCustomQueryChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + onCommitCustomQuery(); + } + }} + onBlur={() => { + if (dirty) onCommitCustomQuery(); + }} + placeholder='Custom query — press Enter. e.g. "is:open review-requested:@me"' + className="h-8 pr-20" + /> + {dirty && ( + + Press Enter + + )} +
+
+ {lastFetchedAt && !loading && ( + + Updated {formatRelativeTime(lastFetchedAt.toISOString())} + + )} + +
+
+ ); +} diff --git a/apps/web/components/github/my-github/pr-list.tsx b/apps/web/components/github/my-github/pr-list.tsx new file mode 100644 index 0000000000..365af14fac --- /dev/null +++ b/apps/web/components/github/my-github/pr-list.tsx @@ -0,0 +1,156 @@ +"use client"; + +import Link from "next/link"; +import { + IconGitPullRequest, + IconGitPullRequestClosed, + IconGitMerge, + IconPlus, + IconChevronDown, +} from "@tabler/icons-react"; +import type { Icon } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; +import { Spinner } from "@kandev/ui/spinner"; +import { cn, formatRelativeTime } from "@/lib/utils"; +import type { GitHubPR, GitHubPRStatus } from "@/lib/types/github"; +import { PR_TASK_PRESETS, type LaunchPayload, type TaskPreset } from "./quick-task-launcher"; +import { PRStatusBadges } from "./pr-status-badges"; +import { prStatusKey, usePRStatuses } from "./use-pr-statuses"; + +type PRListProps = { + items: GitHubPR[]; + loading: boolean; + error: string | null; + onStartTask: (payload: LaunchPayload) => void; +}; + +function prStateIcon(pr: GitHubPR): { Icon: Icon; className: string } { + if (pr.state === "merged") + return { Icon: IconGitMerge, className: "text-purple-600 dark:text-purple-400" }; + if (pr.state === "closed") + return { Icon: IconGitPullRequestClosed, className: "text-red-600 dark:text-red-400" }; + if (pr.draft) return { Icon: IconGitPullRequest, className: "text-muted-foreground" }; + return { Icon: IconGitPullRequest, className: "text-emerald-600 dark:text-emerald-400" }; +} + +function StartTaskMenu({ + pr, + onStartTask, +}: { + pr: GitHubPR; + onStartTask: PRListProps["onStartTask"]; +}) { + const launch = (preset: TaskPreset) => onStartTask({ kind: "pr", pr, preset }); + return ( + + + + + + {PR_TASK_PRESETS.map((p) => { + const ItemIcon = p.icon; + return ( + launch(p)} + > + +
+ {p.label} + {p.hint} +
+
+ ); + })} +
+
+ ); +} + +function PRRow({ + pr, + status, + onStartTask, +}: { + pr: GitHubPR; + status: GitHubPRStatus | null | undefined; + onStartTask: PRListProps["onStartTask"]; +}) { + const { Icon: StateIcon, className: stateIconClass } = prStateIcon(pr); + return ( +
+ +
+ + {pr.title} + +
+ + {pr.repo_owner}/{pr.repo_name}#{pr.number} + + · + + by {pr.author_login} · opened {formatRelativeTime(pr.created_at)} + + +
+
+
+ +
+
+ ); +} + +function PRListBody({ loading, error, items, onStartTask }: PRListProps) { + const statuses = usePRStatuses(items); + if (loading) { + return ( +
+ +
+ ); + } + if (error) { + return
{error}
; + } + if (items.length === 0) { + return ( +
+ No pull requests match this filter. +
+ ); + } + return ( +
+ {items.map((pr) => { + const key = prStatusKey(pr.repo_owner, pr.repo_name, pr.number); + return ; + })} +
+ ); +} + +export function PRList(props: PRListProps) { + return ( +
+ +
+ ); +} diff --git a/apps/web/components/github/my-github/pr-status-badges.tsx b/apps/web/components/github/my-github/pr-status-badges.tsx new file mode 100644 index 0000000000..bb3ff6cde3 --- /dev/null +++ b/apps/web/components/github/my-github/pr-status-badges.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { + IconCheck, + IconX, + IconClockHour4, + IconGitMerge, + IconAlertTriangle, + IconGitPullRequestDraft, +} from "@tabler/icons-react"; +import type { Icon } from "@tabler/icons-react"; +import type { GitHubPR, GitHubPRStatus } from "@/lib/types/github"; +import { cn } from "@/lib/utils"; + +type StatusChipProps = { + Icon: Icon; + label: string; + tone: "success" | "failure" | "pending" | "neutral"; + title?: string; +}; + +function StatusChip({ Icon, label, tone, title }: StatusChipProps) { + const toneClass = { + success: "text-emerald-600 dark:text-emerald-400", + failure: "text-red-600 dark:text-red-400", + pending: "text-amber-600 dark:text-amber-400", + neutral: "text-muted-foreground", + }[tone]; + return ( + + + {label} + + ); +} + +function ChecksChip({ status }: { status: GitHubPRStatus }) { + const { checks_state: state, checks_total: total, checks_passing: passing } = status; + if (!state) return null; + const label = total > 0 ? `${passing}/${total}` : ""; + if (state === "success") + return ; + if (state === "failure") + return ; + return ; +} + +function ReviewChip({ + state, + pending, +}: { + state: GitHubPRStatus["review_state"]; + pending: number; +}) { + if (state === "approved") return ; + if (state === "changes_requested") + return ; + if (pending > 0) + return ( + + ); + return null; +} + +function MergeableChip({ + state, + prState, +}: { + state: GitHubPRStatus["mergeable_state"]; + prState: GitHubPR["state"]; +}) { + if (prState === "merged") return ; + if (state === "draft") + return ; + if (state === "dirty") + return ; + if (state === "blocked") + return ; + if (state === "behind") + return ; + return null; +} + +export function PRStatusBadges({ + pr, + status, +}: { + pr: GitHubPR; + status: GitHubPRStatus | null | undefined; +}) { + if (!status) return null; + return ( + <> + + + + + ); +} diff --git a/apps/web/components/github/my-github/presets-sidebar.tsx b/apps/web/components/github/my-github/presets-sidebar.tsx new file mode 100644 index 0000000000..90904d2c9b --- /dev/null +++ b/apps/web/components/github/my-github/presets-sidebar.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { IconX, IconDeviceFloppy, IconBookmark } from "@tabler/icons-react"; +import type { Icon } from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; +import { PR_PRESETS, ISSUE_PRESETS, type PresetOption, type PresetGroup } from "./search-bar"; +import type { SavedPreset } from "./use-saved-presets"; + +export type SidebarSelection = { + kind: "pr" | "issue"; + source: "preset" | "saved"; + id: string; +}; + +type PresetsSidebarProps = { + selected: SidebarSelection; + onSelect: (s: SidebarSelection) => void; + savedPresets: SavedPreset[]; + onDeleteSaved: (id: string) => void; + canSaveCurrent: boolean; + onSaveCurrent: () => void; +}; + +function KindToggle({ + kind, + onChange, +}: { + kind: "pr" | "issue"; + onChange: (k: "pr" | "issue") => void; +}) { + return ( +
+ {(["pr", "issue"] as const).map((value) => ( + + ))} +
+ ); +} + +function SectionHeader({ title }: { title: string }) { + return ( +
+ {title} +
+ ); +} + +function PresetItem({ + label, + Icon, + active, + onClick, + trailing, +}: { + label: string; + Icon: Icon; + active: boolean; + onClick: () => void; + trailing?: React.ReactNode; +}) { + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + }; + return ( +
+ + {label} + {trailing} +
+ ); +} + +function PresetGroupList({ + presets, + group, + selected, + onSelect, + kind, +}: { + presets: PresetOption[]; + group: PresetGroup; + selected: SidebarSelection; + onSelect: (s: SidebarSelection) => void; + kind: "pr" | "issue"; +}) { + const items = presets.filter((p) => p.group === group); + if (items.length === 0) return null; + return ( + <> + + {items.map((p) => ( + onSelect({ kind, source: "preset", id: p.value })} + /> + ))} + + ); +} + +function SavedSection({ + saved, + selected, + onSelect, + onDelete, + kind, + canSaveCurrent, + onSaveCurrent, +}: { + saved: SavedPreset[]; + selected: SidebarSelection; + onSelect: (s: SidebarSelection) => void; + onDelete: (id: string) => void; + kind: "pr" | "issue"; + canSaveCurrent: boolean; + onSaveCurrent: () => void; +}) { + return ( + <> + + {saved.length === 0 && ( +
+ No saved queries yet. +
+ )} + {saved.map((s) => ( + onSelect({ kind, source: "saved", id: s.id })} + trailing={ + + } + /> + ))} + + + ); +} + +export function PresetsSidebar({ + selected, + onSelect, + savedPresets, + onDeleteSaved, + canSaveCurrent, + onSaveCurrent, +}: PresetsSidebarProps) { + const presets = selected.kind === "pr" ? PR_PRESETS : ISSUE_PRESETS; + const saved = savedPresets.filter((p) => p.kind === selected.kind); + const onKindChange = (kind: "pr" | "issue") => { + const fallback = (kind === "pr" ? PR_PRESETS : ISSUE_PRESETS)[0]?.value ?? ""; + onSelect({ kind, source: "preset", id: fallback }); + }; + return ( + + ); +} diff --git a/apps/web/components/github/my-github/quick-task-launcher.tsx b/apps/web/components/github/my-github/quick-task-launcher.tsx new file mode 100644 index 0000000000..bd779c4680 --- /dev/null +++ b/apps/web/components/github/my-github/quick-task-launcher.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { + IconEye, + IconMessageDots, + IconTool, + IconCode, + IconSearch, + IconBug, +} from "@tabler/icons-react"; +import type { Icon } from "@tabler/icons-react"; +import { TaskCreateDialog } from "@/components/task-create-dialog"; +import type { Repository, Task, Workflow, WorkflowStep } from "@/lib/types/http"; +import type { GitHubPR, GitHubIssue } from "@/lib/types/github"; + +export type TaskPreset = { + id: string; + label: string; + hint: string; + icon: Icon; + prompt: (opts: { url: string; title: string }) => string; +}; + +export type LaunchPayload = + | { kind: "pr"; pr: GitHubPR; preset: TaskPreset } + | { kind: "issue"; issue: GitHubIssue; preset: TaskPreset }; + +export const PR_TASK_PRESETS: TaskPreset[] = [ + { + id: "review", + label: "Review", + hint: "Read the diff, flag issues", + icon: IconEye, + prompt: ({ url }) => + `Review the pull request at ${url}. Provide feedback on code quality, correctness, and suggest improvements.`, + }, + { + id: "address_feedback", + label: "Address feedback", + hint: "Apply review comments", + icon: IconMessageDots, + prompt: ({ url }) => + `Address the review feedback on the pull request at ${url}. Make the requested changes and push them.`, + }, + { + id: "fix_ci", + label: "Fix CI", + hint: "Diagnose failing checks", + icon: IconTool, + prompt: ({ url }) => + `Investigate and fix the CI failures on the pull request at ${url}. Run the failing checks locally, diagnose, and push fixes.`, + }, +]; + +export const ISSUE_TASK_PRESETS: TaskPreset[] = [ + { + id: "implement", + label: "Implement", + hint: "Build and open a PR", + icon: IconCode, + prompt: ({ url, title }) => + `Implement the changes described in the GitHub issue at ${url} (title: "${title}"). Open a pull request when complete.`, + }, + { + id: "investigate", + label: "Investigate", + hint: "Find the root cause", + icon: IconSearch, + prompt: ({ url, title }) => + `Investigate the GitHub issue at ${url} (title: "${title}"). Identify root cause and summarize findings.`, + }, + { + id: "reproduce", + label: "Reproduce", + hint: "Document repro steps", + icon: IconBug, + prompt: ({ url, title }) => + `Reproduce the bug described in the GitHub issue at ${url} (title: "${title}"). Document the reproduction steps.`, + }, +]; + +type DialogState = { + title: string; + description: string; + repositoryId?: string; + branch?: string; + githubUrl?: string; +}; + +function matchRepo(repos: Repository[], owner: string, name: string): Repository | undefined { + return repos.find( + (r) => + (r.provider_owner || "").toLowerCase() === owner.toLowerCase() && + (r.provider_name || "").toLowerCase() === name.toLowerCase(), + ); +} + +function extractPayload(payload: LaunchPayload) { + if (payload.kind === "pr") { + return { + url: payload.pr.html_url, + title: payload.pr.title, + owner: payload.pr.repo_owner, + name: payload.pr.repo_name, + branch: payload.pr.head_branch, + }; + } + return { + url: payload.issue.html_url, + title: payload.issue.title, + owner: payload.issue.repo_owner, + name: payload.issue.repo_name, + branch: undefined as string | undefined, + }; +} + +function buildDialogState(payload: LaunchPayload, repositories: Repository[]): DialogState { + const data = extractPayload(payload); + const repo = matchRepo(repositories, data.owner, data.name); + const description = payload.preset.prompt({ url: data.url, title: data.title }); + const title = `${payload.preset.label}: ${data.title}`; + if (repo) { + return { title, description, repositoryId: repo.id, branch: data.branch }; + } + return { + title, + description, + githubUrl: `github.com/${data.owner}/${data.name}`, + branch: data.branch, + }; +} + +type QuickTaskLauncherProps = { + workspaceId: string | null; + workflows: Workflow[]; + steps: WorkflowStep[]; + repositories: Repository[]; + payload: LaunchPayload | null; + onClose: () => void; +}; + +export function QuickTaskLauncher({ + workspaceId, + workflows, + steps, + repositories, + payload, + onClose, +}: QuickTaskLauncherProps) { + const router = useRouter(); + + const defaultWorkflow = workflows[0]; + const sortedStepsForWorkflow = useMemo( + () => + steps + .filter((s) => s.workflow_id === defaultWorkflow?.id) + .sort((a, b) => a.position - b.position), + [steps, defaultWorkflow], + ); + const defaultStep = sortedStepsForWorkflow[0]; + const stepsForWorkflow = useMemo( + () => sortedStepsForWorkflow.map((s) => ({ id: s.id, title: s.name, events: s.events })), + [sortedStepsForWorkflow], + ); + + const dialog = useMemo( + () => (payload ? buildDialogState(payload, repositories) : null), + [payload, repositories], + ); + + const handleOpenChange = (open: boolean) => { + if (!open) onClose(); + }; + const handleSuccess = (task: Task) => { + onClose(); + router.push(`/t/${task.id}`); + }; + + if (!workspaceId || !defaultWorkflow || !defaultStep || !dialog) return null; + + return ( + + ); +} diff --git a/apps/web/components/github/my-github/results-pagination.tsx b/apps/web/components/github/my-github/results-pagination.tsx new file mode 100644 index 0000000000..187e29c128 --- /dev/null +++ b/apps/web/components/github/my-github/results-pagination.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { Fragment } from "react"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@kandev/ui/pagination"; + +// GitHub's search API caps total_count at 1000 regardless of actual results. +const GITHUB_MAX_RESULTS = 1000; + +type ResultsPaginationProps = { + page: number; + pageSize: number; + total: number; + onPageChange: (page: number) => void; +}; + +function pageWindow(page: number, totalPages: number): number[] { + const pages = new Set([1, totalPages, page - 1, page, page + 1]); + return Array.from(pages) + .filter((p) => p >= 1 && p <= totalPages) + .sort((a, b) => a - b); +} + +export function ResultsPagination({ page, pageSize, total, onPageChange }: ResultsPaginationProps) { + const effectiveTotal = Math.min(total, GITHUB_MAX_RESULTS); + const totalPages = Math.max(1, Math.ceil(effectiveTotal / pageSize)); + if (totalPages <= 1) return null; + + const windowPages = pageWindow(page, totalPages); + const start = (page - 1) * pageSize + 1; + const end = Math.min(page * pageSize, effectiveTotal); + + return ( +
+
+ {start}–{end} of {total > GITHUB_MAX_RESULTS ? `${GITHUB_MAX_RESULTS}+` : total} +
+ + + + { + e.preventDefault(); + if (page > 1) onPageChange(page - 1); + }} + aria-disabled={page <= 1} + className={page <= 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + + {windowPages.map((p, i) => { + const prev = windowPages[i - 1]; + const needsGap = prev !== undefined && p - prev > 1; + return ( + + {needsGap && ( + + + + )} + + { + e.preventDefault(); + onPageChange(p); + }} + className="cursor-pointer" + > + {p} + + + + ); + })} + + { + e.preventDefault(); + if (page < totalPages) onPageChange(page + 1); + }} + aria-disabled={page >= totalPages} + className={page >= totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + + + +
+ ); +} diff --git a/apps/web/components/github/my-github/save-preset-dialog.tsx b/apps/web/components/github/my-github/save-preset-dialog.tsx new file mode 100644 index 0000000000..69b7a75267 --- /dev/null +++ b/apps/web/components/github/my-github/save-preset-dialog.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@kandev/ui/dialog"; +import { Input } from "@kandev/ui/input"; +import { Button } from "@kandev/ui/button"; +import { Label } from "@kandev/ui/label"; + +type SavePresetDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + kind: "pr" | "issue"; + customQuery: string; + repoFilter: string; + suggestedLabel: string; + onSave: (label: string) => void; +}; + +function SavePresetForm({ + kind, + customQuery, + repoFilter, + suggestedLabel, + onSave, + onClose, +}: { + kind: "pr" | "issue"; + customQuery: string; + repoFilter: string; + suggestedLabel: string; + onSave: (label: string) => void; + onClose: () => void; +}) { + const [value, setValue] = useState(suggestedLabel); + const trimmed = value.trim(); + const canSubmit = trimmed.length > 0; + + const handleSubmit = useCallback(() => { + if (!canSubmit) return; + onSave(trimmed); + onClose(); + }, [canSubmit, trimmed, onSave, onClose]); + + return ( + <> + + Save query + + Save this {kind === "pr" ? "pull request" : "issue"} query to the sidebar for quick access + later. + + +
+
+ + setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSubmit(); + }} + onFocus={(e) => e.target.select()} + placeholder="e.g. Needs my review" + /> +
+
+ {customQuery && ( +
+ Query + + {customQuery} + +
+ )} + {repoFilter && ( +
+ Repo + + {repoFilter} + +
+ )} +
+
+ + + + + + ); +} + +export function SavePresetDialog({ + open, + onOpenChange, + kind, + customQuery, + repoFilter, + suggestedLabel, + onSave, +}: SavePresetDialogProps) { + const handleClose = useCallback(() => onOpenChange(false), [onOpenChange]); + return ( + + + {open && ( + + )} + + + ); +} diff --git a/apps/web/components/github/my-github/search-bar.tsx b/apps/web/components/github/my-github/search-bar.tsx new file mode 100644 index 0000000000..e2a3b8aadf --- /dev/null +++ b/apps/web/components/github/my-github/search-bar.tsx @@ -0,0 +1,89 @@ +import { + IconInbox, + IconAt, + IconGitPullRequest, + IconPencil, + IconGitMerge, + IconPlus, + IconCircleCheck, +} from "@tabler/icons-react"; +import type { Icon } from "@tabler/icons-react"; + +export type PresetGroup = "inbox" | "created"; + +export type PresetOption = { + value: string; + label: string; + filter: string; + group: PresetGroup; + icon: Icon; +}; + +export const PR_PRESETS: PresetOption[] = [ + { + value: "review_requested", + label: "Review requested", + filter: "review-requested:@me is:open", + group: "inbox", + icon: IconInbox, + }, + { + value: "mentioned", + label: "Mentions", + filter: "mentions:@me is:open", + group: "inbox", + icon: IconAt, + }, + { + value: "open", + label: "Open", + filter: "author:@me is:open", + group: "created", + icon: IconGitPullRequest, + }, + { + value: "drafts", + label: "Drafts", + filter: "author:@me is:open draft:true", + group: "created", + icon: IconPencil, + }, + { + value: "merged", + label: "Recently merged", + filter: "author:@me is:merged", + group: "created", + icon: IconGitMerge, + }, +]; + +export const ISSUE_PRESETS: PresetOption[] = [ + { + value: "assigned", + label: "Assigned", + filter: "assignee:@me is:open", + group: "inbox", + icon: IconInbox, + }, + { + value: "mentioned", + label: "Mentions", + filter: "mentions:@me is:open", + group: "inbox", + icon: IconAt, + }, + { + value: "created", + label: "Open", + filter: "author:@me is:open", + group: "created", + icon: IconPlus, + }, + { + value: "closed", + label: "Recently closed", + filter: "author:@me is:closed", + group: "created", + icon: IconCircleCheck, + }, +]; diff --git a/apps/web/components/github/my-github/use-committed-query.test.ts b/apps/web/components/github/my-github/use-committed-query.test.ts new file mode 100644 index 0000000000..0308267583 --- /dev/null +++ b/apps/web/components/github/my-github/use-committed-query.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { useCommittedQuery } from "./use-committed-query"; + +describe("useCommittedQuery", () => { + it("initializes draft and committed to the initial value", () => { + const { result } = renderHook(() => useCommittedQuery("hello")); + expect(result.current.draft).toBe("hello"); + expect(result.current.committed).toBe("hello"); + }); + + it("setDraft updates draft without touching committed", () => { + const { result } = renderHook(() => useCommittedQuery("")); + act(() => result.current.setDraft("typed")); + expect(result.current.draft).toBe("typed"); + expect(result.current.committed).toBe(""); + }); + + it("commit syncs committed to the current draft", () => { + const { result } = renderHook(() => useCommittedQuery("")); + act(() => result.current.setDraft("query")); + act(() => result.current.commit()); + expect(result.current.draft).toBe("query"); + expect(result.current.committed).toBe("query"); + }); + + it("setImmediate updates draft and committed together", () => { + const { result } = renderHook(() => useCommittedQuery("")); + act(() => result.current.setImmediate("now")); + expect(result.current.draft).toBe("now"); + expect(result.current.committed).toBe("now"); + }); + + it("commit after setImmediate is a no-op for committed", () => { + const { result } = renderHook(() => useCommittedQuery("")); + act(() => result.current.setImmediate("abc")); + act(() => result.current.commit()); + expect(result.current.committed).toBe("abc"); + }); +}); diff --git a/apps/web/components/github/my-github/use-committed-query.ts b/apps/web/components/github/my-github/use-committed-query.ts new file mode 100644 index 0000000000..11c86ba1fc --- /dev/null +++ b/apps/web/components/github/my-github/use-committed-query.ts @@ -0,0 +1,21 @@ +"use client"; + +import { useCallback, useState } from "react"; + +// Track a user-editable string where the draft only commits when the caller +// explicitly asks. Typing updates `draft` for responsive input display but +// does not trigger downstream fetches on `committed`; Enter or blur in the +// input calls `setImmediate` to commit and run the query. +export function useCommittedQuery(initial: string) { + const [draft, setDraft] = useState(initial); + const [committed, setCommitted] = useState(initial); + + const setImmediate = useCallback((value: string) => { + setDraft(value); + setCommitted(value); + }, []); + + const commit = useCallback(() => setCommitted(draft), [draft]); + + return { draft, committed, setDraft, setImmediate, commit }; +} diff --git a/apps/web/components/github/my-github/use-github-search.test.ts b/apps/web/components/github/my-github/use-github-search.test.ts new file mode 100644 index 0000000000..7e33a6bf09 --- /dev/null +++ b/apps/web/components/github/my-github/use-github-search.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { IconGitPullRequest } from "@tabler/icons-react"; +import { buildParams } from "./use-github-search"; +import type { PresetOption } from "./search-bar"; + +const presets: PresetOption[] = [ + { value: "open", label: "Open", filter: "is:open", group: "inbox", icon: IconGitPullRequest }, + { + value: "closed", + label: "Closed", + filter: "is:closed", + group: "inbox", + icon: IconGitPullRequest, + }, +]; + +describe("buildParams", () => { + it("uses preset filter when custom query is empty", () => { + expect(buildParams(presets, "open", "", "")).toEqual({ filter: "is:open" }); + }); + + it("trims custom query and routes to query branch", () => { + expect(buildParams(presets, "open", " foo bar ", "")).toEqual({ query: "foo bar" }); + }); + + it("appends repo qualifier to custom query", () => { + expect(buildParams(presets, "open", "foo", "acme/widget")).toEqual({ + query: "foo repo:acme/widget", + }); + }); + + it("appends repo qualifier to preset filter", () => { + expect(buildParams(presets, "closed", "", "acme/widget")).toEqual({ + filter: "is:closed repo:acme/widget", + }); + }); + + it("falls back to empty filter when preset is unknown", () => { + expect(buildParams(presets, "missing", "", "")).toEqual({ filter: "" }); + }); + + it("returns repo-only filter when preset is unknown but repo given", () => { + expect(buildParams(presets, "missing", "", "a/b")).toEqual({ filter: "repo:a/b" }); + }); + + it("whitespace-only custom query treated as empty (uses preset)", () => { + expect(buildParams(presets, "open", " ", "")).toEqual({ filter: "is:open" }); + }); +}); diff --git a/apps/web/components/github/my-github/use-github-search.ts b/apps/web/components/github/my-github/use-github-search.ts new file mode 100644 index 0000000000..c696e43907 --- /dev/null +++ b/apps/web/components/github/my-github/use-github-search.ts @@ -0,0 +1,112 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { searchUserPRs, searchUserIssues } from "@/lib/api/domains/github-api"; +import type { GitHubPR, GitHubIssue } from "@/lib/types/github"; +import type { PresetOption } from "./search-bar"; + +type SearchKind = "pr" | "issue"; + +export const SEARCH_PAGE_SIZE = 25; + +type SearchState = { + items: T[]; + loading: boolean; + error: string | null; + lastFetchedAt: Date | null; + total: number; +}; + +type FetchArgs = { + preset: string; + customQuery: string; + repoFilter: string; + page: number; +}; + +export function buildParams( + presets: PresetOption[], + preset: string, + customQuery: string, + repoFilter: string, +) { + const trimmed = customQuery.trim(); + const repoQualifier = repoFilter ? `repo:${repoFilter}` : ""; + if (trimmed) { + return { query: [trimmed, repoQualifier].filter(Boolean).join(" ") }; + } + const found = presets.find((p) => p.value === preset); + const filter = [found?.filter ?? "", repoQualifier].filter(Boolean).join(" "); + return { filter }; +} + +export function useGitHubSearch( + kind: SearchKind, + presets: PresetOption[], + preset: string, + customQuery: string, + repoFilter: string = "", +) { + const [state, setState] = useState>({ + items: [], + loading: false, + error: null, + lastFetchedAt: null, + total: 0, + }); + const [page, setPage] = useState(1); + // Monotonic request counter: responses from older requests are dropped so + // a slow page-N request can't overwrite a fresher page-1 result after a + // filter change. + const requestSeq = useRef(0); + + // Reset to page 1 whenever the filter inputs change. + useEffect(() => { + setPage(1); + }, [preset, customQuery, repoFilter, kind]); + + const fetchData = useCallback( + async ({ preset: ep, customQuery: ec, repoFilter: er, page: epage }: FetchArgs) => { + const seq = ++requestSeq.current; + setState((s) => ({ ...s, loading: true, error: null })); + try { + const base = buildParams(presets, ep, ec, er); + const params = { ...base, page: epage, perPage: SEARCH_PAGE_SIZE }; + const response = + kind === "pr" ? await searchUserPRs(params) : await searchUserIssues(params); + if (seq !== requestSeq.current) return; + const items = (kind === "pr" + ? (response as { prs: GitHubPR[] }).prs + : (response as { issues: GitHubIssue[] }).issues) as unknown as T[]; + setState({ + items: items ?? [], + loading: false, + error: null, + lastFetchedAt: new Date(), + total: response.total_count ?? (items ?? []).length, + }); + } catch (err) { + if (seq !== requestSeq.current) return; + setState((s) => ({ + items: [], + loading: false, + error: err instanceof Error ? err.message : "Failed to search GitHub", + lastFetchedAt: s.lastFetchedAt, + total: 0, + })); + } + }, + [kind, presets], + ); + + useEffect(() => { + void fetchData({ preset, customQuery, repoFilter, page }); + }, [fetchData, preset, customQuery, repoFilter, page]); + + const refresh = useCallback( + () => fetchData({ preset, customQuery, repoFilter, page }), + [fetchData, preset, customQuery, repoFilter, page], + ); + + return { ...state, page, setPage, pageSize: SEARCH_PAGE_SIZE, refresh }; +} diff --git a/apps/web/components/github/my-github/use-known-repos.test.ts b/apps/web/components/github/my-github/use-known-repos.test.ts new file mode 100644 index 0000000000..9c1cfd6ba7 --- /dev/null +++ b/apps/web/components/github/my-github/use-known-repos.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { __getSnapshotForTests, recordForKey } from "./use-known-repos"; + +describe("recordForKey", () => { + beforeEach(() => { + // Each test starts with a fresh key; since recordForKey clears when the + // key changes, this resets the module-level accumulator. + recordForKey(`__reset__${Math.random().toString(36).slice(2)}`, []); + }); + + it("accumulates unique repos under the same key", () => { + recordForKey("k1", ["a/b", "c/d"]); + recordForKey("k1", ["e/f"]); + expect(__getSnapshotForTests()).toEqual(["a/b", "c/d", "e/f"]); + }); + + it("returns sorted output", () => { + recordForKey("k2", ["z/z", "a/a", "m/m"]); + expect(__getSnapshotForTests()).toEqual(["a/a", "m/m", "z/z"]); + }); + + it("deduplicates across successive calls with the same key", () => { + recordForKey("k3", ["a/b"]); + recordForKey("k3", ["a/b", "c/d"]); + expect(__getSnapshotForTests()).toEqual(["a/b", "c/d"]); + }); + + it("clears accumulator when key changes", () => { + recordForKey("k4a", ["a/a", "b/b"]); + recordForKey("k4b", ["x/x"]); + expect(__getSnapshotForTests()).toEqual(["x/x"]); + }); + + it("ignores empty strings", () => { + recordForKey("k5", ["", "a/a", ""]); + expect(__getSnapshotForTests()).toEqual(["a/a"]); + }); + + it("empty input under an existing key is a no-op", () => { + recordForKey("k6", ["a/a"]); + recordForKey("k6", []); + expect(__getSnapshotForTests()).toEqual(["a/a"]); + }); +}); diff --git a/apps/web/components/github/my-github/use-known-repos.ts b/apps/web/components/github/my-github/use-known-repos.ts new file mode 100644 index 0000000000..b45c07fd6e --- /dev/null +++ b/apps/web/components/github/my-github/use-known-repos.ts @@ -0,0 +1,72 @@ +"use client"; + +import { useEffect, useSyncExternalStore } from "react"; + +// Accumulator of repos seen in the *current* query context. Scoped by a +// reset key (kind + selection + custom query) so repos from one preset don't +// bleed into another. Within a single context it only grows, so narrowing +// the repo filter doesn't hide the remaining options. +let currentKey: string | null = null; +const seen = new Set(); +let snapshot: string[] = []; +const emptySnapshot: string[] = []; +const listeners = new Set<() => void>(); + +function subscribe(cb: () => void) { + listeners.add(cb); + return () => { + listeners.delete(cb); + }; +} + +function getSnapshot(): string[] { + return snapshot; +} + +function getServerSnapshot(): string[] { + return emptySnapshot; +} + +export function recordForKey(key: string, repos: readonly string[]) { + let changed = false; + if (currentKey !== key) { + currentKey = key; + if (seen.size > 0) { + seen.clear(); + snapshot = []; + changed = true; + } + } + for (const r of repos) { + if (r && !seen.has(r)) { + seen.add(r); + changed = true; + } + } + if (!changed) return; + snapshot = Array.from(seen).sort(); + for (const l of listeners) l(); +} + +// resetKnownReposStore drops all accumulated repos and the reset-key anchor. +// The /github page calls it on unmount so the store doesn't carry a stale +// set across visits. Also used for test isolation. +export function resetKnownReposStore() { + currentKey = null; + if (seen.size === 0 && snapshot.length === 0) return; + seen.clear(); + snapshot = []; + for (const l of listeners) l(); +} + +export function useKnownRepos(resetKey: string, fromItems: readonly string[]): string[] { + useEffect(() => { + recordForKey(resetKey, fromItems); + }, [resetKey, fromItems]); + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} + +// Test-only: read the current module-level snapshot. +export function __getSnapshotForTests(): string[] { + return snapshot; +} diff --git a/apps/web/components/github/my-github/use-pr-statuses.test.ts b/apps/web/components/github/my-github/use-pr-statuses.test.ts new file mode 100644 index 0000000000..7b85389d9b --- /dev/null +++ b/apps/web/components/github/my-github/use-pr-statuses.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { GitHubPR, GitHubPRStatus } from "@/lib/types/github"; + +const getPRStatusesBatch = vi.fn(); + +vi.mock("@/lib/api/domains/github-api", () => ({ + getPRStatusesBatch: (refs: { owner: string; repo: string; number: number }[]) => + getPRStatusesBatch(refs) as Promise<{ statuses: Record }>, +})); + +import { prStatusKey, usePRStatuses } from "./use-pr-statuses"; + +function makePR(owner: string, repo: string, number: number): GitHubPR { + return { + number, + title: `PR #${number}`, + url: "", + html_url: "", + state: "open", + head_branch: "feat", + base_branch: "main", + author_login: "u", + repo_owner: owner, + repo_name: repo, + draft: false, + mergeable: true, + additions: 0, + deletions: 0, + requested_reviewers: [], + created_at: "", + updated_at: "", + merged_at: null, + closed_at: null, + }; +} + +function makeStatus(pr: GitHubPR): GitHubPRStatus { + return { + pr, + review_state: "", + checks_state: "", + mergeable_state: "unknown", + review_count: 0, + pending_review_count: 0, + checks_total: 0, + checks_passing: 0, + } as GitHubPRStatus; +} + +describe("prStatusKey", () => { + it("encodes owner/repo#number", () => { + expect(prStatusKey("acme", "widget", 7)).toBe("acme/widget#7"); + }); +}); + +describe("usePRStatuses", () => { + beforeEach(() => { + getPRStatusesBatch.mockReset(); + }); + + it("skips fetch when prs list is empty", () => { + renderHook(() => usePRStatuses([])); + expect(getPRStatusesBatch).not.toHaveBeenCalled(); + }); + + it("fetches and populates map keyed by prStatusKey", async () => { + const pr = makePR("acme", "widget", 7); + getPRStatusesBatch.mockResolvedValueOnce({ + statuses: { "acme/widget#7": makeStatus(pr) }, + }); + const { result } = renderHook(() => usePRStatuses([pr])); + await waitFor(() => { + expect(result.current.size).toBe(1); + }); + expect(result.current.get("acme/widget#7")?.pr.number).toBe(7); + }); + + it("does not refetch when list identity changes but key stays the same", async () => { + const pr = makePR("acme", "widget", 7); + getPRStatusesBatch.mockResolvedValue({ + statuses: { "acme/widget#7": makeStatus(pr) }, + }); + const { rerender, result } = renderHook(({ prs }) => usePRStatuses(prs), { + initialProps: { prs: [pr] }, + }); + await waitFor(() => expect(result.current.size).toBe(1)); + expect(getPRStatusesBatch).toHaveBeenCalledTimes(1); + + // New array reference, same content. + rerender({ prs: [makePR("acme", "widget", 7)] }); + // Give effects a tick. + await act(async () => { + await Promise.resolve(); + }); + expect(getPRStatusesBatch).toHaveBeenCalledTimes(1); + }); + + it("refetches when the list key changes", async () => { + const pr1 = makePR("acme", "widget", 7); + const pr2 = makePR("acme", "widget", 8); + getPRStatusesBatch.mockResolvedValue({ + statuses: { "acme/widget#7": makeStatus(pr1), "acme/widget#8": makeStatus(pr2) }, + }); + const { rerender } = renderHook(({ prs }) => usePRStatuses(prs), { + initialProps: { prs: [pr1] }, + }); + await waitFor(() => expect(getPRStatusesBatch).toHaveBeenCalledTimes(1)); + rerender({ prs: [pr1, pr2] }); + await waitFor(() => expect(getPRStatusesBatch).toHaveBeenCalledTimes(2)); + }); + + it("clears stale statuses when key changes and fetch fails", async () => { + const pr = makePR("acme", "widget", 7); + getPRStatusesBatch.mockResolvedValueOnce({ + statuses: { "acme/widget#7": makeStatus(pr) }, + }); + const { result, rerender } = renderHook(({ prs }) => usePRStatuses(prs), { + initialProps: { prs: [pr] }, + }); + await waitFor(() => expect(result.current.size).toBe(1)); + + // New key, then fetch rejects: the stale badges from pr #7 belong to the + // old key and would be misleading, so the catch handler clears them. + const pr2 = makePR("acme", "widget", 8); + getPRStatusesBatch.mockRejectedValueOnce(new Error("boom")); + rerender({ prs: [pr2] }); + await waitFor(() => expect(result.current.size).toBe(0)); + }); +}); diff --git a/apps/web/components/github/my-github/use-pr-statuses.ts b/apps/web/components/github/my-github/use-pr-statuses.ts new file mode 100644 index 0000000000..216a4f7d8b --- /dev/null +++ b/apps/web/components/github/my-github/use-pr-statuses.ts @@ -0,0 +1,61 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { getPRStatusesBatch, type PRStatusRef } from "@/lib/api/domains/github-api"; +import type { GitHubPR, GitHubPRStatus } from "@/lib/types/github"; + +export function prStatusKey(owner: string, repo: string, number: number): string { + return `${owner}/${repo}#${number}`; +} + +// usePRStatuses fetches PR review/checks/mergeable summaries in a single +// batch request instead of N per-row fetches. Results are keyed by +// prStatusKey(owner, repo, number). When the PR list changes (new page, +// different preset) we refetch; the backend caches per-PR so fast +// pagination stays cheap. +export function usePRStatuses(prs: GitHubPR[]): Map { + const [statuses, setStatuses] = useState>(new Map()); + // `completedKey` is set only after a fetch resolves (success or failure), so + // a transient error can be retried on the next render, and React Strict + // Mode's intentional unmount+remount doesn't cause the batch fetch to be + // skipped — the first mount's cleanup fires before the response lands, so + // completedKey stays empty and the second mount retries. + const completedKey = useRef(""); + + const key = prs.map((p) => prStatusKey(p.repo_owner, p.repo_name, p.number)).join(","); + + // We deliberately depend only on `key`: `prs` gets a new array identity on + // every render, and including it would cancel an in-flight request whose + // content hasn't actually changed. The composed `key` string is the + // authoritative content signal; when it matches we reuse the latest `prs` + // closure for building request refs. + useEffect(() => { + if (key === "") return; + if (completedKey.current === key) return; + const refs: PRStatusRef[] = prs.map((p) => ({ + owner: p.repo_owner, + repo: p.repo_name, + number: p.number, + })); + let cancelled = false; + getPRStatusesBatch(refs) + .then((resp) => { + if (cancelled) return; + completedKey.current = key; + setStatuses(new Map(Object.entries(resp.statuses ?? {}))); + }) + .catch(() => { + if (cancelled) return; + // Leave completedKey untouched so the next render retries; only + // clear the currently-rendered statuses if they belong to a now-stale + // key, so a transient error doesn't wipe otherwise-useful badges. + setStatuses((prev) => (prev.size === 0 ? prev : new Map())); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]); + + return statuses; +} diff --git a/apps/web/components/github/my-github/use-saved-presets.test.ts b/apps/web/components/github/my-github/use-saved-presets.test.ts new file mode 100644 index 0000000000..e0bb69daf0 --- /dev/null +++ b/apps/web/components/github/my-github/use-saved-presets.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { readStorage, type SavedPreset } from "./use-saved-presets"; + +const STORAGE_KEY = "kandev:github-presets:v1"; + +function set(raw: string | null) { + if (raw === null) window.localStorage.removeItem(STORAGE_KEY); + else window.localStorage.setItem(STORAGE_KEY, raw); +} + +const valid: SavedPreset = { + id: "p_1", + kind: "pr", + label: "My PRs", + customQuery: "author:@me", + repoFilter: "", + createdAt: "2026-01-01T00:00:00Z", +}; + +describe("readStorage", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("returns empty array when no value is stored", () => { + expect(readStorage()).toEqual([]); + }); + + it("returns empty array for malformed JSON", () => { + set("not-json{"); + expect(readStorage()).toEqual([]); + }); + + it("returns empty array when parsed value is not an array", () => { + set(JSON.stringify({ id: "p_1" })); + expect(readStorage()).toEqual([]); + }); + + it("keeps valid entries", () => { + set(JSON.stringify([valid])); + expect(readStorage()).toEqual([valid]); + }); + + it("drops entries missing an id", () => { + const missingId = { ...valid } as Partial; + delete missingId.id; + set(JSON.stringify([missingId, valid])); + expect(readStorage()).toEqual([valid]); + }); + + it("drops entries with invalid kind", () => { + set(JSON.stringify([{ ...valid, kind: "commit" }, valid])); + expect(readStorage()).toEqual([valid]); + }); + + it("drops non-object entries", () => { + set(JSON.stringify(["string", 42, null, valid])); + expect(readStorage()).toEqual([valid]); + }); + + it("drops entries with non-string label", () => { + set(JSON.stringify([{ ...valid, label: 123 }, valid])); + expect(readStorage()).toEqual([valid]); + }); + + it("accepts issue kind", () => { + const issue: SavedPreset = { ...valid, kind: "issue" }; + set(JSON.stringify([issue])); + expect(readStorage()).toEqual([issue]); + }); +}); diff --git a/apps/web/components/github/my-github/use-saved-presets.ts b/apps/web/components/github/my-github/use-saved-presets.ts new file mode 100644 index 0000000000..b0def7ff27 --- /dev/null +++ b/apps/web/components/github/my-github/use-saved-presets.ts @@ -0,0 +1,90 @@ +"use client"; + +import { useCallback, useSyncExternalStore } from "react"; + +const STORAGE_KEY = "kandev:github-presets:v1"; + +export type SavedPreset = { + id: string; + kind: "pr" | "issue"; + label: string; + customQuery: string; + repoFilter: string; + createdAt: string; +}; + +export function readStorage(): SavedPreset[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (p): p is SavedPreset => + typeof p === "object" && + p !== null && + typeof (p as SavedPreset).id === "string" && + ((p as SavedPreset).kind === "pr" || (p as SavedPreset).kind === "issue") && + typeof (p as SavedPreset).label === "string", + ); + } catch { + return []; + } +} + +function writeStorage(presets: SavedPreset[]) { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets)); + } catch { + /* ignore quota / access errors */ + } +} + +// External store: single source of truth shared across all hook consumers. +const listeners = new Set<() => void>(); +let snapshot: SavedPreset[] | null = null; +const emptySnapshot: SavedPreset[] = []; + +function getSnapshot(): SavedPreset[] { + if (snapshot === null) snapshot = readStorage(); + return snapshot; +} + +function getServerSnapshot(): SavedPreset[] { + return emptySnapshot; +} + +function subscribe(cb: () => void) { + listeners.add(cb); + return () => { + listeners.delete(cb); + }; +} + +function publish(next: SavedPreset[]) { + snapshot = next; + writeStorage(next); + for (const l of listeners) l(); +} + +export function useSavedPresets() { + const presets = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + + const save = useCallback((input: Omit) => { + const preset: SavedPreset = { + ...input, + id: `p_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + createdAt: new Date().toISOString(), + }; + publish([...(snapshot ?? readStorage()), preset]); + return preset; + }, []); + + const remove = useCallback((id: string) => { + publish((snapshot ?? readStorage()).filter((p) => p.id !== id)); + }, []); + + return { presets, save, remove }; +} diff --git a/apps/web/components/kanban/kanban-header.tsx b/apps/web/components/kanban/kanban-header.tsx index 37bc80e21b..f93bc72ef5 100644 --- a/apps/web/components/kanban/kanban-header.tsx +++ b/apps/web/components/kanban/kanban-header.tsx @@ -13,6 +13,7 @@ import { IconMenu2, IconChartBar, IconTimeline, + IconBrandGithub, } from "@tabler/icons-react"; import { KanbanDisplayDropdown } from "../kanban-display-dropdown"; import { RefreshReviewsButton } from "../github/refresh-reviews-button"; @@ -29,6 +30,7 @@ import { useAppStore } from "@/components/state-provider"; import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings"; import { useReleaseNotes } from "@/hooks/use-release-notes"; import { useSystemHealthIndicator } from "@/hooks/use-system-health-indicator"; +import { useGitHubStatus } from "@/hooks/domains/github/use-github-status"; type KanbanHeaderProps = { onCreateTask: () => void; @@ -51,6 +53,23 @@ const VIEW_TOGGLE_ITEMS: ViewToggleItem[] = [ { value: "list", icon: IconList, label: "List" }, ]; +function GitHubTopbarButton() { + const { status } = useGitHubStatus(); + if (!status?.authenticated) return null; + return ( + + + + + My GitHub — PRs & issues + + ); +} + function ViewToggleGroup({ toggleValue, onValueChange, @@ -155,6 +174,7 @@ function TabletHeader({ className="h-8" itemClassName="h-8 w-8" /> + {showReleaseNotesButton && } @@ -233,6 +253,7 @@ function DesktopHeader({ Stats + {showReleaseNotesButton && } diff --git a/apps/web/components/task-create-dialog-state.ts b/apps/web/components/task-create-dialog-state.ts index 855faa8d8f..eca1eadac3 100644 --- a/apps/web/components/task-create-dialog-state.ts +++ b/apps/web/components/task-create-dialog-state.ts @@ -112,7 +112,7 @@ function useFormResetEffects({ useEffect(() => { if (!open) return; - resetDiscoveryState(resetters); + resetDiscoveryState(resetters, initialValues); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, workspaceId]); } @@ -161,14 +161,15 @@ function resetTaskForm( } /** Resets repository discovery state */ -function resetDiscoveryState(resetters: FormResetters) { +function resetDiscoveryState(resetters: FormResetters, iv?: TaskCreateDialogInitialValues) { + const ghUrl = iv?.githubUrl ?? ""; resetters.setDiscoveredRepositories([]); resetters.setDiscoveredRepoPath(""); resetters.setSelectedLocalRepo(null); resetters.setLocalBranches([]); resetters.setDiscoverReposLoaded(false); - resetters.setUseGitHubUrl(false); - resetters.setGitHubUrl(""); + resetters.setUseGitHubUrl(Boolean(ghUrl)); + resetters.setGitHubUrl(ghUrl); resetters.setGitHubBranches([]); resetters.setGitHubUrlError(null); resetters.setGitHubPrHeadBranch(null); diff --git a/apps/web/components/task-create-dialog-types.ts b/apps/web/components/task-create-dialog-types.ts index 0f3184a9d5..273d566101 100644 --- a/apps/web/components/task-create-dialog-types.ts +++ b/apps/web/components/task-create-dialog-types.ts @@ -30,6 +30,9 @@ export type TaskCreateDialogInitialValues = { repositoryId?: string; branch?: string; state?: Task["state"]; + /** When set, opens the dialog in GitHub URL mode pre-filled with this value + * (e.g. "github.com/owner/repo"). Used when no matching workspace repo exists. */ + githubUrl?: string; }; export type StoreSelections = { diff --git a/apps/web/lib/api/domains/github-api.ts b/apps/web/lib/api/domains/github-api.ts index c8d61511e4..1cf82b432f 100644 --- a/apps/web/lib/api/domains/github-api.ts +++ b/apps/web/lib/api/domains/github-api.ts @@ -19,6 +19,9 @@ import type { CreateIssueWatchRequest, UpdateIssueWatchRequest, TriggerIssueResponse, + SearchPRsResponse, + SearchIssuesResponse, + GitHubPRStatus, } from "@/lib/types/github"; // Status @@ -54,6 +57,32 @@ export async function getPRFeedback( return fetchJson(`/api/v1/github/prs/${owner}/${repo}/${number}`, options); } +// Lightweight PR status (review + checks + mergeable), skips comments. +export async function getPRStatus( + owner: string, + repo: string, + number: number, + options?: ApiRequestOptions, +) { + return fetchJson(`/api/v1/github/prs/${owner}/${repo}/${number}/status`, options); +} + +export type PRStatusRef = { owner: string; repo: string; number: number }; + +// Batch variant of getPRStatus: one round-trip for a whole list page. The +// backend fans out concurrently and caches per-PR, so repeat calls for the +// same page are cheap. Keys in the returned map are "/#". +export async function getPRStatusesBatch(refs: PRStatusRef[], options?: ApiRequestOptions) { + return fetchJson<{ statuses: Record }>(`/api/v1/github/prs/statuses`, { + ...options, + init: { + method: "POST", + body: JSON.stringify({ refs }), + ...(options?.init ?? {}), + }, + }); +} + // Submit PR review export async function submitPRReview( owner: string, @@ -242,3 +271,38 @@ export async function triggerAllIssueWatches(workspaceId: string, options?: ApiR }, ); } + +// User PR / issue search (for the /github page). +// Pass `query` to use a verbatim GitHub search string, or `filter` to append to +// the default (type:pr state:open / type:issue state:open). +type SearchParams = { + query?: string; + filter?: string; + page?: number; + perPage?: number; +}; + +function buildSearchQuery(params: SearchParams) { + const search = new URLSearchParams(); + if (params.query) search.set("query", params.query); + if (params.filter) search.set("filter", params.filter); + if (params.page && params.page > 1) search.set("page", String(params.page)); + if (params.perPage) search.set("per_page", String(params.perPage)); + return search.toString(); +} + +export async function searchUserPRs(params: SearchParams, options?: ApiRequestOptions) { + const suffix = buildSearchQuery(params); + return fetchJson( + `/api/v1/github/user/prs${suffix ? `?${suffix}` : ""}`, + options, + ); +} + +export async function searchUserIssues(params: SearchParams, options?: ApiRequestOptions) { + const suffix = buildSearchQuery(params); + return fetchJson( + `/api/v1/github/user/issues${suffix ? `?${suffix}` : ""}`, + options, + ); +} diff --git a/apps/web/lib/types/github.ts b/apps/web/lib/types/github.ts index 8ddc0dafed..aadd449a41 100644 --- a/apps/web/lib/types/github.ts +++ b/apps/web/lib/types/github.ts @@ -86,6 +86,17 @@ export type PRFeedback = { has_issues: boolean; }; +export type GitHubPRStatus = { + pr: GitHubPR; + review_state: "approved" | "changes_requested" | "pending" | ""; + checks_state: "success" | "failure" | "pending" | ""; + mergeable_state: MergeableState; + review_count: number; + pending_review_count: number; + checks_total: number; + checks_passing: number; +}; + export type MergeableState = | "clean" | "blocked" @@ -294,3 +305,35 @@ export type PRCommitInfo = { deletions: number; files_changed: number; }; + +// GitHub Issue (separate from Pull Request) +export type GitHubIssue = { + number: number; + title: string; + body: string; + url: string; + html_url: string; + state: "open" | "closed"; + author_login: string; + repo_owner: string; + repo_name: string; + labels: string[]; + assignees: string[]; + created_at: string; + updated_at: string; + closed_at: string | null; +}; + +export type SearchPRsResponse = { + prs: GitHubPR[]; + total_count: number; + page: number; + per_page: number; +}; + +export type SearchIssuesResponse = { + issues: GitHubIssue[]; + total_count: number; + page: number; + per_page: number; +};