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 <noreply@anthropic.com> * style: apply Prettier formatting Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * style: apply Prettier formatting Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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+`"`)
|
||||
|
||||
Reference in New Issue
Block a user