Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type App struct {
CurrentUser string
Config config.Config
Cache *cache.Cache
SkipStore *cache.SkipStore
Skills []skills.Skill
}

Expand Down Expand Up @@ -100,6 +101,7 @@ func New(repoDirs []string) (*App, error) {
CurrentUser: user,
Config: cfg,
Cache: cache.Load(),
SkipStore: cache.LoadSkipStore(),
Skills: discovered,
}

Expand Down
88 changes: 88 additions & 0 deletions internal/cache/skip.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package cache

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"

"github.com/sleuth-io/prx/internal/dirs"
"github.com/sleuth-io/prx/internal/logger"
)

// SkipStore persists a set of skipped PRs keyed by "repo#number".
type SkipStore struct {
mu sync.Mutex
skipped map[string]bool
path string
}

func LoadSkipStore() *SkipStore {
path := skipPath()
s := &SkipStore{path: path, skipped: make(map[string]bool)}
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return s
}
if err != nil {
logger.Error("reading skip store: %v", err)
return s
}
if err := json.Unmarshal(data, &s.skipped); err != nil {
logger.Error("parsing skip store: %v", err)
}
logger.Info("loaded %d skipped PRs", len(s.skipped))
return s
}

func SkipKey(repo string, number int) string {
return fmt.Sprintf("%s#%d", repo, number)
}

func (s *SkipStore) IsSkipped(key string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.skipped[key]
}

func (s *SkipStore) Skip(key string) {
s.mu.Lock()
defer s.mu.Unlock()
s.skipped[key] = true
if err := s.save(); err != nil {
logger.Error("saving skip store: %v", err)
}
}

func (s *SkipStore) Unskip(key string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.skipped, key)
if err := s.save(); err != nil {
logger.Error("saving skip store: %v", err)
}
}

func (s *SkipStore) save() error {
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
return err
}
data, err := json.MarshalIndent(s.skipped, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0644)
}

func skipPath() string {
dir, err := dirs.GetCacheDir()
if err != nil {
xdg := os.Getenv("XDG_CACHE_HOME")
if xdg == "" {
xdg = filepath.Join(os.Getenv("HOME"), ".cache")
}
dir = filepath.Join(xdg, "prx")
}
return filepath.Join(dir, "skipped.json")
}
17 changes: 17 additions & 0 deletions internal/github/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,23 @@ func ListMergedPRsMeta(repo, currentUser string, since time.Time) ([]map[string]
return filtered, nil
}

// FetchPRMeta returns lightweight metadata for a single PR by number.
func FetchPRMeta(repo string, number int) (map[string]any, error) {
out, err := exec.Command("gh", "pr", "view",
fmt.Sprintf("%d", number),
"--repo", repo,
"--json", "number,title,author,url,createdAt,additions,deletions,files,body,reviewRequests,headRefOid,headRefName,mergeStateStatus,state",
).Output()
if err != nil {
return nil, fmt.Errorf("gh pr view %d: %w", number, err)
}
var raw map[string]any
if err := json.Unmarshal(out, &raw); err != nil {
return nil, fmt.Errorf("parsing pr view %d: %w", number, err)
}
return raw, nil
}

func getDiff(repo string, number int) (string, error) {
out, err := exec.Command("gh", "pr", "diff", fmt.Sprintf("%d", number), "--repo", repo).Output()
if err != nil {
Expand Down
17 changes: 17 additions & 0 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,23 @@ func (s *Server) notifyConfigReload() {
})
}

// notifySkip signals the TUI to reload its skip store and update visibility.
func (s *Server) notifySkip() {
if s.socketPath == "" {
return
}
conn, err := net.Dial("unix", s.socketPath)
if err != nil {
logger.Error("mcp: notify skip: %v", err)
return
}
defer func() { _ = conn.Close() }()
_ = json.NewEncoder(conn).Encode(map[string]interface{}{
"type": "skip",
"pr_number": s.prNumber,
})
}

// notifyRefresh signals the TUI to re-fetch this PR's data.
func (s *Server) notifyRefresh() {
if s.socketPath == "" {
Expand Down
16 changes: 16 additions & 0 deletions internal/mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"strings"

"github.com/sleuth-io/prx/internal/cache"
"github.com/sleuth-io/prx/internal/config"
"github.com/sleuth-io/prx/internal/github"
)
Expand Down Expand Up @@ -53,6 +54,14 @@ var toolDefs = []map[string]interface{}{
"required": []string{"body"},
},
},
{
"name": "skip_pr",
"description": "Skip the pull request — hides it from the review queue until un-skipped",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
},
{
"name": "merge_pr",
"description": "Merge the pull request",
Expand Down Expand Up @@ -232,6 +241,8 @@ func (s *Server) toolDescription(name string, args map[string]interface{}) strin
return fmt.Sprintf("Post inline comment on PR #%d at %s:%d: %s", s.prNumber, path, int(line), truncate(body, 100))
}
return fmt.Sprintf("Post comment on PR #%d: %s", s.prNumber, truncate(body, 100))
case "skip_pr":
return fmt.Sprintf("Skip PR #%d", s.prNumber)
case "merge_pr":
return fmt.Sprintf("Merge PR #%d", s.prNumber)
case "set_model":
Expand Down Expand Up @@ -288,6 +299,11 @@ func (s *Server) executeAction(name string, args map[string]interface{}) (string
return "", err
}
return "Comment posted successfully", nil
case "skip_pr":
store := cache.LoadSkipStore()
store.Skip(cache.SkipKey(s.repo, s.prNumber))
s.notifySkip()
return "PR skipped successfully", nil
case "merge_pr":
cfg, err := config.Load()
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions internal/mcp/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ var toolMetas = map[string]toolMeta{
"approve_pr": {requiresPermission: true, isMutation: true},
"request_changes": {requiresPermission: true, isMutation: true},
"comment_on_pr": {requiresPermission: true, isMutation: true},
"skip_pr": {requiresPermission: false, isMutation: false},
"merge_pr": {requiresPermission: true, isMutation: true},
"get_config": {requiresPermission: false, isMutation: false},
"set_model": {requiresPermission: true, isMutation: false},
Expand Down
11 changes: 11 additions & 0 deletions internal/reviewstate/reviewstate.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,17 @@ func (s *Store) Get(key string) *PRState {
return s.states[key]
}

// Keys returns all store keys (repo#number format).
func (s *Store) Keys() []string {
s.mu.Lock()
defer s.mu.Unlock()
keys := make([]string, 0, len(s.states))
for k := range s.states {
keys = append(keys, k)
}
return keys
}

// Set stores the review state for a PR and persists to disk.
func (s *Store) Set(key string, state *PRState) {
s.mu.Lock()
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/bulk_approve_scene.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func (s *BulkApproveScene) Update(msg tea.Msg, m *Model) (Scene, tea.Cmd) {
if msg.String() == "ctrl+r" {
var cmds []tea.Cmd
for _, r := range m.app.Repos {
cmds = append(cmds, fetchPRListCmd(r), fetchMergedPRListCmd(r))
cmds = append(cmds, fetchPRListCmd(r), fetchMergedPRListCmd(r), fetchTrackedPRListCmd(r, m.reviewStore, nil))
}
return s, tea.Batch(cmds...)
}
Expand Down
35 changes: 35 additions & 0 deletions internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/sleuth-io/prx/internal/imgrender"
"github.com/sleuth-io/prx/internal/logger"
"github.com/sleuth-io/prx/internal/mcp"
"github.com/sleuth-io/prx/internal/reviewstate"
"github.com/sleuth-io/prx/internal/tui/chat"
"github.com/sleuth-io/prx/internal/tui/diff"
)
Expand Down Expand Up @@ -163,6 +164,40 @@ func fetchMergedPRListCmd(ctx *app.RepoContext) tea.Cmd {
}
}

// fetchTrackedPRListCmd fetches metadata for PRs we've previously interacted with
// (from the review state store) that aren't already in the card set.
func fetchTrackedPRListCmd(ctx *app.RepoContext, store *reviewstate.Store, existingNums map[int]bool) tea.Cmd {
// Collect PR numbers from the store for this repo that we don't already have.
var toFetch []int
prefix := ctx.Repo + "#"
for _, key := range store.Keys() {
if !strings.HasPrefix(key, prefix) {
continue
}
numStr := key[len(prefix):]
num, err := strconv.Atoi(numStr)
if err != nil {
continue
}
if existingNums[num] {
continue
}
toFetch = append(toFetch, num)
}
return func() tea.Msg {
var rawPRs []map[string]any
for _, num := range toFetch {
raw, err := github.FetchPRMeta(ctx.Repo, num)
if err != nil {
logger.Info("tracked PR #%d fetch failed: %v", num, err)
continue
}
rawPRs = append(rawPRs, raw)
}
return trackedPRListFetchedMsg{ctx: ctx, rawPRs: rawPRs}
}
}

func fetchMergedPRStatusCmd(repo string, number int, currentUser string) tea.Cmd {
return func() tea.Msg {
hasReview, hasReaction, err := github.FetchPRReviewAndReactionStatus(repo, number, currentUser)
Expand Down
2 changes: 2 additions & 0 deletions internal/tui/conversation_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,10 @@ func (s *ConversationScene) handleActionDone(msg actionDoneMsg, m *Model) (Scene
}
case actionApprove:
s.actionStatus = fmt.Sprintf("Approved PR #%d", msg.pr)
m.addLocalReview(msg.repo, msg.pr, "APPROVED")
case actionRequestChanges:
s.actionStatus = fmt.Sprintf("Requested changes on PR #%d", msg.pr)
m.addLocalReview(msg.repo, msg.pr, "CHANGES_REQUESTED")
case actionPostMergeApprove:
s.actionStatus = fmt.Sprintf("Approved post-merge PR #%d \U0001f44d", msg.pr)
m.markPostMergeReacted(msg.repo, msg.pr, "+1")
Expand Down
10 changes: 2 additions & 8 deletions internal/tui/conversation_scene.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,14 +274,8 @@ func (s *ConversationScene) renderFooter(m *Model) string {
if width == 0 {
width = 80
}
visible := m.visibleCardCount()
visIdx := 0
for i := 0; i < m.current && i < len(m.cards); i++ {
if m.isCardVisible(m.cards[i]) {
visIdx++
}
}
status := fmt.Sprintf("prx PR %d/%d", visIdx+1, visible)
visIdx, visible := m.visiblePosition()
status := fmt.Sprintf("prx PR %d/%d", visIdx, visible)
if s.actionStatus != "" && s.actionDone {
status += fmt.Sprintf(" %s", s.actionStatus)
} else if s.actionStatus != "" {
Expand Down
12 changes: 9 additions & 3 deletions internal/tui/diff/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,24 @@ func CommentBodyHash(body string) string {
}

// CommentDigestsFromPR produces comment digests from a PR's inline and top-level comments.
func CommentDigestsFromPR(pr *github.PR) []reviewstate.CommentDigest {
// If excludeUser is provided, comments by that user are excluded — this prevents
// the current user's own comments from being tracked or flagged as new.
func CommentDigestsFromPR(pr *github.PR, excludeUser ...string) []reviewstate.CommentDigest {
exclude := ""
if len(excludeUser) > 0 {
exclude = excludeUser[0]
}
var digests []reviewstate.CommentDigest
for _, c := range pr.InlineComments {
if c.ID != 0 {
if c.ID != 0 && c.Author != exclude {
digests = append(digests, reviewstate.CommentDigest{
ID: c.ID,
Hash: CommentBodyHash(c.Body),
})
}
}
for _, c := range pr.Comments {
if c.ID != 0 {
if c.ID != 0 && c.Author != exclude {
digests = append(digests, reviewstate.CommentDigest{
ID: c.ID,
Hash: CommentBodyHash(c.Body),
Expand Down
Loading
Loading