85aa3ac0aa
* 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>
165 lines
5.1 KiB
Go
165 lines
5.1 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
)
|
|
|
|
// stubClient implements Client with no-op defaults; override fields as needed.
|
|
type stubClient struct {
|
|
getPRFunc func(ctx context.Context, owner, repo string, number int) (*PR, error)
|
|
}
|
|
|
|
func (s *stubClient) IsAuthenticated(context.Context) (bool, error) { return true, nil }
|
|
func (s *stubClient) GetAuthenticatedUser(context.Context) (string, error) {
|
|
return "test-user", nil
|
|
}
|
|
func (s *stubClient) GetPR(ctx context.Context, owner, repo string, number int) (*PR, error) {
|
|
if s.getPRFunc != nil {
|
|
return s.getPRFunc(ctx, owner, repo, number)
|
|
}
|
|
return nil, fmt.Errorf("not implemented")
|
|
}
|
|
func (s *stubClient) FindPRByBranch(context.Context, string, string, string) (*PR, error) {
|
|
return nil, nil
|
|
}
|
|
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
|
|
}
|
|
func (s *stubClient) ListUserOrgs(context.Context) ([]GitHubOrg, error) { return nil, nil }
|
|
func (s *stubClient) SearchOrgRepos(context.Context, string, string, int) ([]GitHubRepo, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubClient) ListPRReviews(context.Context, string, string, int) ([]PRReview, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubClient) ListPRComments(context.Context, string, string, int, *time.Time) ([]PRComment, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubClient) ListCheckRuns(context.Context, string, string, string) ([]CheckRun, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubClient) GetPRFeedback(context.Context, string, string, int) (*PRFeedback, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubClient) ListPRFiles(context.Context, string, string, int) ([]PRFile, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubClient) ListPRCommits(context.Context, string, string, int) ([]PRCommitInfo, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubClient) SubmitReview(context.Context, string, string, int, string, string) error {
|
|
return nil
|
|
}
|
|
func (s *stubClient) ListRepoBranches(context.Context, string, string) ([]RepoBranch, error) {
|
|
return nil, nil
|
|
}
|
|
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
|
|
}
|
|
func (s *stubClient) GetPRStatus(context.Context, string, string, int) (*PRStatus, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func newControllerTestLogger() *logger.Logger {
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{
|
|
Level: "error",
|
|
Format: "json",
|
|
})
|
|
return log
|
|
}
|
|
|
|
func setupControllerTest(client Client) (*gin.Engine, *Controller) {
|
|
gin.SetMode(gin.TestMode)
|
|
log := newControllerTestLogger()
|
|
svc := NewService(client, "pat", nil, nil, nil, log)
|
|
ctrl := NewController(svc, log)
|
|
router := gin.New()
|
|
ctrl.RegisterHTTPRoutes(router)
|
|
return router, ctrl
|
|
}
|
|
|
|
func TestHttpGetPRInfo_Success(t *testing.T) {
|
|
sc := &stubClient{
|
|
getPRFunc: func(_ context.Context, owner, repo string, number int) (*PR, error) {
|
|
if owner != "acme" || repo != "widget" || number != 42 {
|
|
t.Errorf("unexpected params: %s/%s#%d", owner, repo, number)
|
|
}
|
|
return &PR{Number: 42, Title: "feat: add widget"}, nil
|
|
},
|
|
}
|
|
router, _ := setupControllerTest(sc)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/github/prs/acme/widget/42/info", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var pr PR
|
|
if err := json.NewDecoder(w.Body).Decode(&pr); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
if pr.Number != 42 {
|
|
t.Errorf("expected PR number 42, got %d", pr.Number)
|
|
}
|
|
if pr.Title != "feat: add widget" {
|
|
t.Errorf("expected title 'feat: add widget', got %q", pr.Title)
|
|
}
|
|
}
|
|
|
|
func TestHttpGetPRInfo_InvalidNumber(t *testing.T) {
|
|
router, _ := setupControllerTest(&stubClient{})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/github/prs/acme/widget/abc/info", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestHttpGetPRInfo_ServiceError(t *testing.T) {
|
|
sc := &stubClient{
|
|
getPRFunc: func(context.Context, string, string, int) (*PR, error) {
|
|
return nil, fmt.Errorf("not found")
|
|
},
|
|
}
|
|
router, _ := setupControllerTest(sc)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/github/prs/acme/widget/99/info", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Fatalf("expected 500, got %d", w.Code)
|
|
}
|
|
}
|