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:
João Salavisa
2026-04-24 10:38:49 +01:00
committed by GitHub
parent 7e938f6bdb
commit 85aa3ac0aa
42 changed files with 3688 additions and 27 deletions
+164 -8
View File
@@ -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+`"`)