Skip to content

Commit cb95cd0

Browse files
Thomas StrombergThomas Stromberg
authored andcommitted
fix collaborator API access
1 parent a0a73af commit cb95cd0

File tree

2 files changed

+127
-5
lines changed

2 files changed

+127
-5
lines changed

pkg/prx/collaborators_cache.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,41 @@ func newCollaboratorsCache(cacheDir string) *collaboratorsCache {
4343
return cc
4444
}
4545

46+
// get retrieves a cached collaborators list if it exists and is not expired.
47+
func (cc *collaboratorsCache) get(owner, repo string) (map[string]string, bool) {
48+
key := fmt.Sprintf("%s/%s", owner, repo)
49+
50+
cc.mu.RLock()
51+
defer cc.mu.RUnlock()
52+
53+
entry, exists := cc.memory[key]
54+
if !exists {
55+
return nil, false
56+
}
57+
58+
// Check if cache entry is expired
59+
if time.Since(entry.CachedAt) > collaboratorsCacheDuration {
60+
return nil, false
61+
}
62+
63+
return entry.Collaborators, true
64+
}
65+
66+
// set stores a collaborators list in the cache.
67+
func (cc *collaboratorsCache) set(owner, repo string, collaborators map[string]string) error {
68+
key := fmt.Sprintf("%s/%s", owner, repo)
69+
70+
cc.mu.Lock()
71+
cc.memory[key] = collaboratorsEntry{
72+
Collaborators: collaborators,
73+
CachedAt: time.Now(),
74+
}
75+
cc.mu.Unlock()
76+
77+
// Save to disk after releasing the lock
78+
return cc.saveToDisk()
79+
}
80+
4681
// loadFromDisk loads the cache from disk.
4782
func (cc *collaboratorsCache) loadFromDisk() error {
4883
// Skip if no disk path is set (in-memory only mode)
@@ -75,3 +110,36 @@ func (cc *collaboratorsCache) loadFromDisk() error {
75110

76111
return nil
77112
}
113+
114+
// saveToDisk saves the current cache to disk.
115+
func (cc *collaboratorsCache) saveToDisk() error {
116+
// Skip if no disk path is set (in-memory only mode)
117+
if cc.diskPath == "" {
118+
return nil
119+
}
120+
121+
// Create a copy to avoid holding the lock during I/O
122+
cc.mu.RLock()
123+
cacheCopy := make(map[string]collaboratorsEntry, len(cc.memory))
124+
for k, v := range cc.memory {
125+
cacheCopy[k] = v
126+
}
127+
cc.mu.RUnlock()
128+
129+
data, err := json.Marshal(cacheCopy)
130+
if err != nil {
131+
return fmt.Errorf("marshaling collaborators cache: %w", err)
132+
}
133+
134+
// Write to temp file first, then rename for atomicity
135+
tempFile := cc.diskPath + ".tmp"
136+
if err := os.WriteFile(tempFile, data, 0o600); err != nil {
137+
return fmt.Errorf("writing collaborators cache: %w", err)
138+
}
139+
140+
if err := os.Rename(tempFile, cc.diskPath); err != nil {
141+
return fmt.Errorf("renaming collaborators cache: %w", err)
142+
}
143+
144+
return nil
145+
}

pkg/prx/graphql_complete.go

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1471,7 +1471,7 @@ func (*Client) parseGraphQLTimelineEvent(_ /* ctx */ context.Context, item map[s
14711471
}
14721472

14731473
// writeAccessFromAssociation calculates write access from association.
1474-
func (*Client) writeAccessFromAssociation(_ /* ctx */ context.Context, _ /* owner */, _ /* repo */, user, association string) int {
1474+
func (c *Client) writeAccessFromAssociation(ctx context.Context, owner, repo, user, association string) int {
14751475
if user == "" {
14761476
return WriteAccessNA
14771477
}
@@ -1480,17 +1480,71 @@ func (*Client) writeAccessFromAssociation(_ /* ctx */ context.Context, _ /* owne
14801480
case "OWNER", "COLLABORATOR":
14811481
return WriteAccessDefinitely
14821482
case "MEMBER":
1483-
// For MEMBER, we'd need an additional API call to check permissions
1484-
// This is the one case where GraphQL doesn't give us everything
1485-
// For now, return likely
1486-
return WriteAccessLikely
1483+
// For MEMBER, check collaborators cache to determine actual permission level
1484+
// Members can have various permissions (admin, write, read) so we need to check
1485+
return c.checkCollaboratorPermission(ctx, owner, repo, user)
14871486
case "CONTRIBUTOR", "NONE", "FIRST_TIME_CONTRIBUTOR", "FIRST_TIMER":
14881487
return WriteAccessUnlikely
14891488
default:
14901489
return WriteAccessNA
14911490
}
14921491
}
14931492

1493+
// checkCollaboratorPermission checks if a user has write access by looking them up in the collaborators list.
1494+
// Uses cache to avoid repeated API calls (4 hour TTL).
1495+
func (c *Client) checkCollaboratorPermission(ctx context.Context, owner, repo, user string) int {
1496+
// Check cache first
1497+
if collabs, ok := c.collaboratorsCache.get(owner, repo); ok {
1498+
switch collabs[user] {
1499+
case "admin", "maintain", "write":
1500+
return WriteAccessDefinitely
1501+
case "read", "triage", "none":
1502+
return WriteAccessNo
1503+
default:
1504+
// User not in collaborators list
1505+
return WriteAccessUnlikely
1506+
}
1507+
}
1508+
1509+
// Cache miss - fetch collaborators from API
1510+
gc, ok := c.github.(*githubClient)
1511+
if !ok {
1512+
// Not a real GitHub client (probably test mock) - return likely as fallback
1513+
return WriteAccessLikely
1514+
}
1515+
1516+
collabs, err := gc.collaborators(ctx, owner, repo)
1517+
if err != nil {
1518+
// API call failed (could be 403 if no permission to list collaborators)
1519+
// Return likely as fallback
1520+
c.logger.WarnContext(ctx, "failed to fetch collaborators for write access check",
1521+
"owner", owner,
1522+
"repo", repo,
1523+
"user", user,
1524+
"error", err)
1525+
return WriteAccessLikely
1526+
}
1527+
1528+
// Store in cache
1529+
if err := c.collaboratorsCache.set(owner, repo, collabs); err != nil {
1530+
// Cache write failed, just log it and continue
1531+
c.logger.WarnContext(ctx, "failed to cache collaborators",
1532+
"owner", owner,
1533+
"repo", repo,
1534+
"error", err)
1535+
}
1536+
1537+
switch collabs[user] {
1538+
case "admin", "maintain", "write":
1539+
return WriteAccessDefinitely
1540+
case "read", "triage", "none":
1541+
return WriteAccessNo
1542+
default:
1543+
// User not in collaborators list
1544+
return WriteAccessUnlikely
1545+
}
1546+
}
1547+
14941548
// extractRequiredChecksFromGraphQL gets required checks from GraphQL response.
14951549
func (*Client) extractRequiredChecksFromGraphQL(data *graphQLPullRequestComplete) []string {
14961550
checkMap := make(map[string]bool)

0 commit comments

Comments
 (0)