Add sh.tangled.repo.listPulls XRPC endpoint for querying all PRs by repository #930

closed
opened by vitorpy.com targeting master from vitorpy.com/tangled-core: feature/list-all-pulls

Summary#

Adds a new XRPC endpoint sh.tangled.repo.listPulls that allows listing all pull requests targeting a specific repository, regardless of author. This enables the CLI to show all PRs for a repo instead of only the user's own PRs.

Changes#

  • appview/pulls/pulls.go: Add ListPulls() handler that queries the database for all PRs targeting a given repository AT-URI, with optional state filtering (open/closed/merged)
  • appview/state/router.go: Register the new XRPC endpoint at /xrpc/sh.tangled.repo.listPulls
  • appview/state/xrpc.go: Add delegation method to route requests to the pulls handler
  • lexicons/repo/listPulls.json: Define the lexicon schema for the new XRPC method

API#

Endpoint: GET /xrpc/sh.tangled.repo.listPulls Parameters:

  • repo (required): AT-URI of the target repository
  • state (optional): Filter by state - "open", "closed", or "merged" Response: JSON array of pull request summaries with owner DID, pull ID, title, state, target branch, and creation timestamp

Use Case#

This enables CLI commands like:

tangled pr list --repo tangled-cli --state open

To show all open PRs targeting the repository, not just those created by the current user.

Backwards Compatibility

✅ No breaking changes - this is a new additive endpoint
Changed files
+198
appview
pulls
state
lexicons
+89
appview/pulls/pulls.go
··· 691 691 }) 692 692 } 693 693 694 + // ListPulls is an XRPC method that lists all pull requests targeting a repository 695 + func (s *Pulls) ListPulls(w http.ResponseWriter, r *http.Request) { 696 + l := s.logger.With("handler", "ListPulls") 697 + 698 + // Parse query parameters 699 + params := r.URL.Query() 700 + repoAtStr := params.Get("repo") // AT-URI of target repo 701 + stateParam := params.Get("state") // "open", "closed", "merged", or empty for all 702 + 703 + if repoAtStr == "" { 704 + http.Error(w, "repo parameter is required", http.StatusBadRequest) 705 + return 706 + } 707 + 708 + // Parse AT-URI and validate 709 + repoAt, err := syntax.ParseATURI(repoAtStr) 710 + if err != nil { 711 + l.Error("invalid repo AT-URI", "err", err, "repoAt", repoAtStr) 712 + http.Error(w, "invalid repo AT-URI", http.StatusBadRequest) 713 + return 714 + } 715 + 716 + // Determine state filter 717 + var stateFilter orm.Filter 718 + switch stateParam { 719 + case "open": 720 + stateFilter = orm.FilterEq("state", models.PullOpen) 721 + case "closed": 722 + stateFilter = orm.FilterEq("state", models.PullClosed) 723 + case "merged": 724 + stateFilter = orm.FilterEq("state", models.PullMerged) 725 + case "": 726 + // No state filter - include all states 727 + stateFilter = orm.FilterIn("state", []models.PullState{ 728 + models.PullOpen, 729 + models.PullClosed, 730 + models.PullMerged, 731 + }) 732 + default: 733 + http.Error(w, "invalid state parameter", http.StatusBadRequest) 734 + return 735 + } 736 + 737 + // Query database 738 + pulls, err := db.GetPulls( 739 + s.db, 740 + orm.FilterEq("repo_at", repoAt.String()), 741 + stateFilter, 742 + ) 743 + if err != nil { 744 + l.Error("failed to list pulls", "err", err) 745 + http.Error(w, "failed to list pulls", http.StatusInternalServerError) 746 + return 747 + } 748 + 749 + l.Debug("listed pulls", "count", len(pulls), "repo_at", repoAt.String()) 750 + 751 + // Build response 752 + type PullSummary struct { 753 + Rkey string `json:"rkey"` 754 + OwnerDid string `json:"ownerDid"` 755 + PullId int `json:"pullId"` 756 + Title string `json:"title"` 757 + State int `json:"state"` 758 + TargetBranch string `json:"targetBranch"` 759 + CreatedAt string `json:"createdAt"` 760 + } 761 + 762 + type Response struct { 763 + Pulls []PullSummary `json:"pulls"` 764 + } 765 + 766 + summaries := make([]PullSummary, 0, len(pulls)) 767 + for _, p := range pulls { 768 + summaries = append(summaries, PullSummary{ 769 + Rkey: p.Rkey, 770 + OwnerDid: p.OwnerDid, 771 + PullId: p.PullId, 772 + Title: p.Title, 773 + State: int(p.State), 774 + TargetBranch: p.TargetBranch, 775 + CreatedAt: p.Created.Format(time.RFC3339), 776 + }) 777 + } 778 + 779 + w.Header().Set("Content-Type", "application/json") 780 + json.NewEncoder(w).Encode(Response{Pulls: summaries}) 781 + } 782 + 694 783 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 695 784 user := s.oauth.GetUser(r) 696 785 f, err := s.repoResolver.Resolve(r)
+3
appview/state/router.go
··· 143 143 144 144 r.With(middleware.Paginate).Get("/goodfirstissues", s.GoodFirstIssues) 145 145 146 + // XRPC endpoints 147 + r.Get("/xrpc/sh.tangled.repo.listPulls", s.ListPulls) 148 + 146 149 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 147 150 r.Post("/", s.Follow) 148 151 r.Delete("/", s.Follow)
+26
appview/state/xrpc.go
··· 1 + package state 2 + 3 + import ( 4 + "net/http" 5 + 6 + "tangled.org/core/appview/pulls" 7 + ) 8 + 9 + // ListPulls delegates to the pulls.ListPulls XRPC handler 10 + func (s *State) ListPulls(w http.ResponseWriter, r *http.Request) { 11 + pullsHandler := pulls.New( 12 + s.oauth, 13 + s.repoResolver, 14 + s.pages, 15 + s.idResolver, 16 + s.mentionsResolver, 17 + s.db, 18 + s.config, 19 + s.notifier, 20 + s.enforcer, 21 + s.validator, 22 + s.indexer.Pulls, 23 + s.logger.With("handler", "pulls-xrpc"), 24 + ) 25 + pullsHandler.ListPulls(w, r) 26 + }
+80
lexicons/repo/listPulls.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.listPulls", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": [ 10 + "repo" 11 + ], 12 + "properties": { 13 + "repo": { 14 + "type": "string", 15 + "format": "at-uri" 16 + }, 17 + "state": { 18 + "type": "string", 19 + "enum": ["open", "closed", "merged"] 20 + } 21 + } 22 + }, 23 + "output": { 24 + "encoding": "application/json", 25 + "schema": { 26 + "type": "object", 27 + "required": [ 28 + "pulls" 29 + ], 30 + "properties": { 31 + "pulls": { 32 + "type": "array", 33 + "items": { 34 + "type": "ref", 35 + "ref": "#pull" 36 + } 37 + } 38 + } 39 + } 40 + } 41 + }, 42 + "pull": { 43 + "type": "object", 44 + "required": [ 45 + "rkey", 46 + "ownerDid", 47 + "pullId", 48 + "title", 49 + "state", 50 + "targetBranch", 51 + "createdAt" 52 + ], 53 + "properties": { 54 + "rkey": { 55 + "type": "string" 56 + }, 57 + "ownerDid": { 58 + "type": "string", 59 + "format": "did" 60 + }, 61 + "pullId": { 62 + "type": "integer" 63 + }, 64 + "title": { 65 + "type": "string" 66 + }, 67 + "state": { 68 + "type": "integer" 69 + }, 70 + "targetBranch": { 71 + "type": "string" 72 + }, 73 + "createdAt": { 74 + "type": "string", 75 + "format": "datetime" 76 + } 77 + } 78 + } 79 + } 80 + }